Real EstateParse → Extract

Appraisal Report QC Extractor

Extracts property details, market value opinions, and appraisal metrics from residential appraisal reports.

Ship it with Extend

Live pipeline

a real document, processed end to end · view only
Source documenturar_practice__digital.pdf

Step-by-step

A Uniform Residential Appraisal Report is a standardized document prepared by a licensed appraiser that provides a comprehensive valuation of residential property, including borrower information, property characteristics, neighborhood analysis, and the appraiser's opinion of market value for mortgage lending purposes. This template takes in Uniform Residential Appraisal Reports and outputs markdown (.md) capturing the appraisal report's full text and layout structure, and JSON (.json) with extracted valuation and property fields including file number, address, borrower/lender information, assignment type, market value opinion, taxes, and HOA fees by using Extend's Parse, Extract primitives.

Input
Uniform Residential Appraisal Reports
Compatible document types (full list)
.pdf.docx.xlsx.png.jpg.jpeg.tiff.tif.svg.heic.heif.bmp.gif.webp.psd.xls.xltm.xltx.ods.doc.wpd.dotx.odt.pptx.ppt.ppm.csv.txt.html.xml.rtf.lis.md.eml.pcx
Step 1

Parse

Converts the document into clean, layout-aware markdown plus structured blocks with spatial metadata.

InputSource document — PDF, image, spreadsheet, presentation, or scan
Config
blockOptions.text.agentic.enabledtruechanged
chunkingStrategy.type"document"
engine"parse_performance"
OutputMarkdown chunked by page or section, plus typed blocks (text, table, figure) with bounding boxes

You can learn more about Parse configuration in Extend's Parse documentation.

Step 2

Extract

Pulls a defined set of fields from the document and returns them as structured JSON matching a schema.

InputOutput of the Parse step
Config
schemacustom schema — 12 fieldschanged
advancedOptions.advancedMultimodalEnabledtruechanged
advancedOptions.reviewAgent.enabledtruechanged
baseProcessor"extraction_performance"
OutputJSON shaped to the extraction schema, with per-field confidence scores and citations grounding each value to its source location

You can learn more about Extract configuration in Extend's Extract documentation.

Example code

{
  "name": "Appraisal Report Processing Pipeline",
  "steps": [
    {
      "name": "startTrigger1",
      "type": "TRIGGER",
      "next": [
        {
          "step": "parse1"
        }
      ]
    },
    {
      "name": "parse1",
      "type": "PARSE",
      "config": {
        "parseConfig": {
          "blockOptions": {
            "text": {
              "agentic": {
                "enabled": true
              }
            }
          },
          "chunkingStrategy": {
            "type": "document"
          }
        }
      },
      "next": [
        {
          "step": "extraction2"
        }
      ]
    },
    {
      "name": "extraction2",
      "type": "EXTRACT",
      "config": {
        "extractorConfig": {
          "schema": {
            "type": "object",
            "properties": {
              "city": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "City where the property is located"
              },
              "state": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "State abbreviation where the property is located"
              },
              "county": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "County where the property is located"
              },
              "hoa_fees": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Annual or monthly HOA fees amount"
              },
              "zip_code": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Postal zip code of the property"
              },
              "file_number": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Unique identifier for the appraisal report"
              },
              "lender_name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Name of the lender/client institution"
              },
              "borrower_name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Name of the borrower/applicant"
              },
              "assignment_type": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Type of assignment (Purchase Transaction, Refinance Transaction, Other)"
              },
              "property_address": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Full street address of the subject property"
              },
              "market_value_opinion": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Appraiser's opinion of the market value of the subject property"
              },
              "annual_property_taxes": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Annual real estate taxes in dollars"
              }
            }
          },
          "baseProcessor": "extraction_performance",
          "advancedOptions": {
            "reviewAgent": {
              "enabled": true
            },
            "advancedMultimodalEnabled": true
          }
        }
      }
    }
  ]
}
# Appraisal Report Processing — Extend AI Skill

## What this pipeline does

This pipeline ingests a Uniform Residential Appraisal Report (URAR), parses it into structured markdown for downstream analysis, and extracts 12 key fields (borrower name, property address, market value opinion, tax information, etc.) into a JSON object suitable for mortgage origination systems, CRM intake, or lending decisioning workflows.

## When to use this

- **Mortgage origination**: Automatically populate borrower and property data from appraisals into your LOS (loan origination system) or CRM without manual data entry.
- **Lender quality assurance**: Parse appraisals at scale, extract compliance-critical fields (assignment type, file number, appraiser opinion), and flag missing or inconsistent values.
- **Secondary market delivery**: Extract standardized appraisal metadata (county, zip, market value) to bundle with loan packages for selling to Fannie Mae, Freddie Mac, or private investors.
- **Real estate research**: Parse a batch of appraisals to analyze neighborhood trends, property characteristics, and market values across regions.
- **Appraisal management platform**: Ingest scanned or digital URAR forms, extract structured data, and create a searchable database of completed appraisals.

## Processor pipeline

### Step 1: Parse — convert URAR to markdown
- **Processor**: `parse_performance` (agentic OCR mode)
- **Purpose**: Convert the multi-page URAR form (often scanned, with handwritten fields and complex table layouts) into clean, readable markdown while preserving spatial relationships and section structure.
- **Key config**:
  - `blockOptions.text.agentic.enabled: true` — enables intelligent form field recognition; detects checkboxes, filled forms, and handwritten notes.
  - `chunkingStrategy.type: "document"` — keeps the entire appraisal as one logical chunk rather than splitting by page; URAR forms have cross-references and summary sections that should remain intact for extraction.
- **Why this config**: URAR forms are dense, standardized layouts with field boxes, tables (comparable sales, property characteristics), and mixed printed/handwritten content. Agentic OCR correctly interprets form structure; document-level chunking ensures the extractor sees the full context (e.g., "market value opinion" is on page 1 but justified by comparables on page 2).

### Step 2: Extract — pull structured fields into JSON
- **Processor**: `extraction_performance` with `reviewAgent` enabled and `advancedMultimodalEnabled: true`
- **Purpose**: Map the parsed markdown to 12 key fields (file number, borrower name, property address, city, state, zip, county, lender name, assignment type, market value opinion, annual property taxes, HOA fees).
- **Key config**:
  - `baseProcessor: "extraction_performance"` — high-accuracy extractor tuned for dense forms and numerical fields (taxes, HOA fees, valuation); slower than `extraction_light` but essential for financial accuracy.
  - `reviewAgent: enabled` — a secondary LLM reviews extracted values against the parsed text to catch hallucinations or misalignments; critical for regulated lending data.
  - `advancedMultimodalEnabled: true` — allows the extractor to re-examine the original image/PDF if confidence is low on a field; useful for appraisals with handwritten market values or complex property descriptions.
- **Why this config**: Appraisal data (property tax amounts, appraisal fees, market values) must be 100% accurate for lending decisions. The review agent and multimodal fallback reduce the risk of transcription errors.

## TypeScript implementation

```typescript
import { ExtendClient } from "extend-ai";
import { z } from "zod";
import fs from "fs";

const client = new ExtendClient({ token: process.env.EXTEND_API_KEY });

export async function processAppraisalReport(filePath: string) {
  // Convert local file to data URL for SDK compatibility
  const fileContent = fs.readFileSync(filePath);
  const dataUrl = `data:application/octet-stream;base64,${fileContent.toString("base64")}`;

  console.log(`Processing appraisal report: ${filePath}`);

  // Step 1: Parse the URAR to markdown
  console.log("Step 1: Parsing appraisal report to markdown...");
  const parseRun = await client.parseRuns.createAndPoll({
    file: { url: dataUrl },
    config: {
      blockOptions: {
        text: {
          agentic: {
            enabled: true,
          },
        },
      },
      chunkingStrategy: {
        type: "document",
      },
    },
  });

  if (parseRun.status !== "PROCESSED") {
    throw new Error(`Parse failed with status: ${parseRun.status}`);
  }

  // Collect parsed markdown for inspection or downstream use
  const markdown = parseRun.output.chunks.map((chunk) => chunk.content).join("\n\n");
  console.log(`Parsed markdown length: ${markdown.length} characters`);

  // Step 2: Extract structured appraisal fields
  console.log("Step 2: Extracting appraisal fields...");
  const extractRun = await client.extractRuns.createAndPoll({
    file: { url: dataUrl },
    config: {
      schema: z.object({
        file_number: z.string().nullable().describe("Unique identifier for the appraisal report"),
        property_address: z.string().nullable().describe("Full street address of the subject property"),
        city: z.string().nullable().describe("City where the property is located"),
        state: z.string().nullable().describe("State abbreviation where the property is located"),
        zip_code: z.string().nullable().describe("Postal zip code of the property"),
        county: z.string().nullable().describe("County where the property is located"),
        borrower_name: z.string().nullable().describe("Name of the borrower/applicant"),
        lender_name: z.string().nullable().describe("Name of the lender/client institution"),
        assignment_type: z
          .string()
          .nullable()
          .describe("Type of assignment: Purchase Transaction, Refinance Transaction, or Other"),
        market_value_opinion: z
          .string()
          .nullable()
          .describe("Appraiser's opinion of the market value of the subject property, e.g. $450,000"),
        annual_property_taxes: z
          .string()
          .nullable()
          .describe("Annual real estate taxes in dollars, e.g. $3,500 or $3,500/year"),
        hoa_fees: z
          .string()
          .nullable()
          .describe("Annual or monthly HOA fees amount, e.g. $2,400/year or $200/month"),
      }),
      baseProcessor: "extraction_performance",
      advancedOptions: {
        reviewAgent: {
          enabled: true,
        },
        advancedMultimodalEnabled: true,
      },
    },
  });

  if (extractRun.status !== "PROCESSED") {
    throw new Error(`Extraction failed with status: ${extractRun.status}`);
  }

  const extractedData = extractRun.output.value;

  // Step 3: Format and return results
  console.log("\n=== Appraisal Report Extraction Results ===");
  console.log(JSON.stringify(extractedData, null, 2));

  return {
    parsed: {
      markdown: markdown,
      chunkCount: parseRun.output.chunks.length,
    },
    extracted: extractedData,
    status: "success",
  };
}

// Main entry point for testing
(async () => {
  const filePath = process.argv[2] || "./appraisal_sample.pdf";
  try {
    const result = await processAppraisalReport(filePath);
    console.log("\nPipeline completed successfully.");
    console.log("Extracted data ready for downstream systems.");
  } catch (error) {
    console.error("Pipeline failed:", error);
    process.exit(1);
  }
})();
```

## CLI equivalent

```bash
# Export your API key
export EXTEND_API_KEY="sk_..."

# Step 1: Parse the appraisal report to markdown
extend parse appraisal_sample.pdf \
  --block-options-text-agentic-enabled true \
  --chunking-strategy-type document

# Step 2: Extract structured fields (requires schema.json)
extend extract appraisal_sample.pdf \
  --schema schema.json \
  --base-processor extraction_performance \
  --review-agent-enabled true \
  --advanced-multimodal-enabled true
```

**schema.json**:
```json
{
  "type": "object",
  "properties": {
    "file_number": {
      "type": ["string", "null"],
      "description": "Unique identifier for the appraisal report"
    },
    "property_address": {
      "type": ["string", "null"],
      "description": "Full street address of the subject property"
    },
    "city": {
      "type": ["string", "null"],
      "description": "City where the property is located"
    },
    "state": {
      "type": ["string", "null"],
      "description": "State abbreviation where the property is located"
    },
    "zip_code": {
      "type": ["string", "null"],
      "description": "Postal zip code of the property"
    },
    "county": {
      "type": ["string", "null"],
      "description": "County where the property is located"
    },
    "borrower_name": {
      "type": ["string", "null"],
      "description": "Name of the borrower/applicant"
    },
    "lender_name": {
      "type": ["string", "null"],
      "description": "Name of the lender/client institution"
    },
    "assignment_type": {
      "type": ["string", "null"],
      "description": "Type of assignment: Purchase Transaction, Refinance Transaction, or Other"
    },
    "market_value_opinion": {
      "type": ["string", "null"],
      "description": "Appraiser's opinion of the market value of the subject property"
    },
    "annual_property_taxes": {
      "type": ["string", "null"],
      "description": "Annual real estate taxes in dollars, e.g. $3,500 or $3,500/year"
    },
    "hoa_fees": {
      "type": ["string", "null"],
      "description": "Annual or monthly HOA fees amount, e.g. $2,400/year or $200/month"
    }
  }
}
```

## Schema

The extraction schema uses 12 fields, all nullable strings (no required fields):

| Field | Type | Description | Example |
|-------|------|-------------|---------|
| `file_number` | string/null | Unique identifier printed on the appraisal | `"AP-2024-001234"` |
| `property_address` | string/null | Full street address of the subject property | `"123 Oak Street"` |
| `city` | string/null | City where the property is located | `"Portland"` |
| `state` | string/null | Two-letter state abbreviation | `"OR"` |
| `zip_code` | string/null | Five- or nine-digit postal code | `"97201"` or `"97201-1234"` |
| `county` | string/null | County name where the property is located | `"Multnomah County"` |
| `borrower_name` | string/null | Full legal name of the borrower/applicant | `"John Smith"` or `"John and Jane Smith"` |
| `lender_name` | string/null | Name of the lending institution | `"First National Bank"` |
| `assignment_type` | string/null | Transaction type: `Purchase Transaction`, `Refinance Transaction`, or `Other` | `"Refinance Transaction"` |
| `market_value_opinion` | string/null | Appraiser's final value opinion; capture the currency amount as written | `"$450,000"` or `"$450000"` |
| `annual_property_taxes` | string/null | Annual property tax amount in dollars; include currency symbol or "per year" clarifier | `"$3,500"` or `"$3,500/year"` |
| `hoa_fees` | string/null | Annual or monthly homeowners association fees; specify unit (e.g., `/month`, `/year`) | `"$200/month"` or `"$2,400/year"` |

**Why these descriptions matter for accuracy:**
- **file_number**: Appraiser IDs and report identifiers are often in small print or handwritten; explicit mention of "printed on the appraisal" helps the model prioritize the formal header.
- **property_address, city, state, zip, county**: These must be 100% accurate for lending system matching. Explicit street/city/state separation reduces swapped fields.
- **borrower_name**: May include both spouses; explicit mention of "full legal name" prevents truncation.
- **market_value_opinion, annual_property_taxes, hoa_fees**: Currency formatting varies (with/without `$`, with/without commas). Including format examples in descriptions prevents parsing errors.
- **assignment_type**: Loan origination systems route on this value; listing the three standard options prevents free-form interpretation.

## Accuracy tips

1. **Calibrate descriptions for form variance** (highest impact)
   - URAR forms are standardized, but lenders sometimes use variants or customized templates. If you see systematic misses on a field, update its description to include examples from your actual appraisals (e.g., if borrower names are always "First and Last Name" in your workflow, say so in the description).

2. **Enable review agent for high-value extractions**
   - `reviewAgent: enabled` catches hallucinations on market value opinion and tax amounts. In production, review agent adds ~20–30% latency but reduces errors by ~40% on financial fields. Always use it for lending.

3. **Use advanced multimodal for handwritten fields**
   - If your appraisals have handwritten market values, appraiser signatures, or notes in margins, keep `advancedMultimodalEnabled: true`. It instructs the extractor to re-examine the PDF image if text-based confidence drops below a threshold.

4. **Separate currency from semantics**
   - Capture `market_value_opinion` as a string (e.g., `"$450,000"`) rather than trying to parse to a number yourself. The string format is unambiguous for downstream validation; let your database or LOS parse and normalize.

5. **Test with scanned vs. digital PDFs**
   - URAR forms may be scanned (image-heavy) or exported directly from appraisal software (native PDF text). The agentic OCR handles both, but validate on a sample of each in your workflow. If scans fail, ensure PDF quality is ≥150 DPI.

6. **Validate address fields against a postal database**
   - After extraction, run `property_address`, `city`, `state`, and `zip_code` through a service like SmartyStreets or USPS validation. Appraisals sometimes have typos; a silent mismatch can break loan delivery.

7. **Flag missing assignment_type**
   - If `assignment_type` is null, escalate to human review before sending to underwriting. Lenders require this field to apply the correct valuation rules (purchase vs. refi).

8. **Monitor for outlier market values**
   - Extract `market_value_opinion` as a string, but parse and log it as a number. If the value is >5% off recent comparable sales for the neighborhood, flag for manual appraisal review.

## Trade-offs & alternatives

### Sync vs. async parsing
- **This skill uses sync parse** (`parseRuns.createAndPoll()`) — good for single appraisals in real-time workflows, but blocks until parse completes (~5–30 seconds depending on page count and image quality).
- **Use async** (`parseRuns.create()` + polling) if you are batch-processing 100+ appraisals; submit all in parallel, poll in batches, and avoid blocking your web server.

### Agentic OCR vs. light mode
- **This skill uses agentic OCR** (`blockOptions.text.agentic.enabled: true`) — best for scanned URAR forms with checkboxes, filled form fields, and handwriting.
- **Switch to light mode** (`agentic: false
import { ExtendClient } from "extend-ai";
import { z } from "zod";
import fs from "fs";

const client = new ExtendClient({ token: process.env.EXTEND_API_KEY });

export async function processAppraisalReport(filePath: string) {
  // Convert local file to data URL for SDK compatibility
  const fileContent = fs.readFileSync(filePath);
  const dataUrl = `data:application/octet-stream;base64,${fileContent.toString("base64")}`;

  console.log(`Processing appraisal report: ${filePath}`);

  // Step 1: Parse the URAR to markdown
  console.log("Step 1: Parsing appraisal report to markdown...");
  const parseRun = await client.parseRuns.createAndPoll({
    file: { url: dataUrl },
    config: {
      blockOptions: {
        text: {
          agentic: {
            enabled: true,
          },
        },
      },
      chunkingStrategy: {
        type: "document",
      },
    },
  });

  if (parseRun.status !== "PROCESSED") {
    throw new Error(`Parse failed with status: ${parseRun.status}`);
  }

  // Collect parsed markdown for inspection or downstream use
  const markdown = parseRun.output.chunks.map((chunk) => chunk.content).join("\n\n");
  console.log(`Parsed markdown length: ${markdown.length} characters`);

  // Step 2: Extract structured appraisal fields
  console.log("Step 2: Extracting appraisal fields...");
  const extractRun = await client.extractRuns.createAndPoll({
    file: { url: dataUrl },
    config: {
      schema: z.object({
        file_number: z.string().nullable().describe("Unique identifier for the appraisal report"),
        property_address: z.string().nullable().describe("Full street address of the subject property"),
        city: z.string().nullable().describe("City where the property is located"),
        state: z.string().nullable().describe("State abbreviation where the property is located"),
        zip_code: z.string().nullable().describe("Postal zip code of the property"),
        county: z.string().nullable().describe("County where the property is located"),
        borrower_name: z.string().nullable().describe("Name of the borrower/applicant"),
        lender_name: z.string().nullable().describe("Name of the lender/client institution"),
        assignment_type: z
          .string()
          .nullable()
          .describe("Type of assignment: Purchase Transaction, Refinance Transaction, or Other"),
        market_value_opinion: z
          .string()
          .nullable()
          .describe("Appraiser's opinion of the market value of the subject property, e.g. $450,000"),
        annual_property_taxes: z
          .string()
          .nullable()
          .describe("Annual real estate taxes in dollars, e.g. $3,500 or $3,500/year"),
        hoa_fees: z
          .string()
          .nullable()
          .describe("Annual or monthly HOA fees amount, e.g. $2,400/year or $200/month"),
      }),
      baseProcessor: "extraction_performance",
      advancedOptions: {
        reviewAgent: {
          enabled: true,
        },
        advancedMultimodalEnabled: true,
      },
    },
  });

  if (extractRun.status !== "PROCESSED") {
    throw new Error(`Extraction failed with status: ${extractRun.status}`);
  }

  const extractedData = extractRun.output.value;

  // Step 3: Format and return results
  console.log("\n=== Appraisal Report Extraction Results ===");
  console.log(JSON.stringify(extractedData, null, 2));

  return {
    parsed: {
      markdown: markdown,
      chunkCount: parseRun.output.chunks.length,
    },
    extracted: extractedData,
    status: "success",
  };
}

// Main entry point for testing
(async () => {
  const filePath = process.argv[2] || "./appraisal_sample.pdf";
  try {
    const result = await processAppraisalReport(filePath);
    console.log("\nPipeline completed successfully.");
    console.log("Extracted data ready for downstream systems.");
  } catch (error) {
    console.error("Pipeline failed:", error);
    process.exit(1);
  }
})();
import os
import base64
from extend_ai import Extend

client = Extend(token=os.environ["EXTEND_API_KEY"])


async def process_appraisal_report(file_path: str):
    # Convert local file to data URL for SDK compatibility
    with open(file_path, "rb") as f:
        file_content = f.read()
    data_url = f"data:application/octet-stream;base64,{base64.b64encode(file_content).decode('utf-8')}"

    print(f"Processing appraisal report: {file_path}")

    # Step 1: Parse the URAR to markdown
    print("Step 1: Parsing appraisal report to markdown...")
    parse_run = await client.parse_runs.create_and_poll(
        file={"url": data_url},
        config={
            "blockOptions": {
                "text": {
                    "agentic": {
                        "enabled": True,
                    },
                },
            },
            "chunkingStrategy": {
                "type": "document",
            },
        },
    )

    if parse_run.status != "PROCESSED":
        raise Exception(f"Parse failed with status: {parse_run.status}")

    # Collect parsed markdown for inspection or downstream use
    markdown = "\n\n".join(chunk.content for chunk in parse_run.output.chunks)
    print(f"Parsed markdown length: {len(markdown)} characters")

    # Step 2: Extract structured appraisal fields
    print("Step 2: Extracting appraisal fields...")
    extract_run = await client.extract_runs.create_and_poll(
        file={"url": data_url},
        config={
            "schema": {
                "type": "object",
                "properties": {
                    "file_number": {
                        "type": ["string", "null"],
                        "description": "Unique identifier for the appraisal report",
                    },
                    "property_address": {
                        "type": ["string", "null"],
                        "description": "Full street address of the subject property",
                    },
                    "city": {
                        "type": ["string", "null"],
                        "description": "City where the property is located",
                    },
                    "state": {
                        "type": ["string", "null"],
                        "description": "State abbreviation where the property is located",
                    },
                    "zip_code": {
                        "type": ["string", "null"],
                        "description": "Postal zip code of the property",
                    },
                    "county": {
                        "type": ["string", "null"],
                        "description": "County where the property is located",
                    },
                    "borrower_name": {
                        "type": ["string", "null"],
                        "description": "Name of the borrower/applicant",
                    },
                    "lender_name": {
                        "type": ["string", "null"],
                        "description": "Name of the lender/client institution",
                    },
                    "assignment_type": {
                        "type": ["string", "null"],
                        "description": "Type of assignment: Purchase Transaction, Refinance Transaction, or Other",
                    },
                    "market_value_opinion": {
                        "type": ["string", "null"],
                        "description": "Appraiser's opinion of the market value of the subject property, e.g. $450,000",
                    },
                    "annual_property_taxes": {
                        "type": ["string", "null"],
                        "description": "Annual real estate taxes in dollars, e.g. $3,500 or $3,500/year",
                    },
                    "hoa_fees": {
                        "type": ["string", "null"],
                        "description": "Annual or monthly HOA fees amount, e.g. $2,400/year or $200/month",
                    },
                },
            },
            "baseProcessor": "extraction_performance",
            "advancedOptions": {
                "reviewAgent": {
                    "enabled": True,
                },
                "advancedMultimodalEnabled": True,
            },
        },
    )

    if extract_run.status != "PROCESSED":
        raise Exception(f"Extraction failed with status: {extract_run.status}")

    extracted_data = extract_run.output.value

    # Step 3: Format and return results
    print("\n=== Appraisal Report Extraction Results ===")
    import json
    print(json.dumps(extracted_data, indent=2))

    return {
        "parsed": {
            "markdown": markdown,
            "chunkCount": len(parse_run.output.chunks),
        },
        "extracted": extracted_data,
        "status": "success",
    }


# Main entry point for testing
async def main():
    import sys
    file_path = sys.argv[1] if len(sys.argv) > 1 else "./appraisal_sample.pdf"
    try:
        result = await process_appraisal_report(file_path)
        print("\nPipeline completed successfully.")
        print("Extracted data ready for downstream systems.")
    except Exception as error:
        print(f"Pipeline failed: {error}")
        exit(1)


if __name__ == "__main__":
    import asyncio
    asyncio.run(main())
// This code uses the Extend REST API directly because Extend has no official Java SDK yet.
// It calls https://api.extend.ai endpoints with Bearer token authentication.

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class AppraisalReportProcessor {
  private static final String API_BASE = "https://api.extend.ai";
  private static final String API_KEY = System.getenv("EXTEND_API_KEY");
  private final HttpClient httpClient;

  public AppraisalReportProcessor() {
    this.httpClient = HttpClient.newHttpClient();
  }

  public static void main(String[] args) throws Exception {
    String filePath = args.length > 0 ? args[0] : "./appraisal_sample.pdf";
    try {
      AppraisalReportProcessor processor = new AppraisalReportProcessor();
      Map<String, Object> result = processor.processAppraisalReport(filePath);
      System.out.println("\nPipeline completed successfully.");
      System.out.println("Extracted data ready for downstream systems.");
    } catch (Exception e) {
      System.err.println("Pipeline failed: " + e.getMessage());
      e.printStackTrace();
      System.exit(1);
    }
  }

  public Map<String, Object> processAppraisalReport(String filePath) throws Exception {
    // Convert local file to data URL for API compatibility
    byte[] fileContent = Files.readAllBytes(Paths.get(filePath));
    String base64Content = Base64.getEncoder().encodeToString(fileContent);
    String dataUrl = "data:application/octet-stream;base64," + base64Content;

    System.out.println("Processing appraisal report: " + filePath);

    // Step 1: Parse the appraisal to markdown
    System.out.println("Step 1: Parsing appraisal report to markdown...");
    Map<String, Object> parseResponse = callParseRun(dataUrl);
    String parseStatus = (String) parseResponse.get("status");
    
    if (!"PROCESSED".equals(parseStatus)) {
      throw new Exception("Parse failed with status: " + parseStatus);
    }

    Map<String, Object> parseOutput = (Map<String, Object>) parseResponse.get("output");
    StringBuilder markdown = new StringBuilder();
    java.util.List<Map<String, Object>> chunks = 
        (java.util.List<Map<String, Object>>) parseOutput.get("chunks");
    for (Map<String, Object> chunk : chunks) {
      markdown.append(chunk.get("content")).append("\n\n");
    }
    
    System.out.println("Parsed markdown length: " + markdown.length() + " characters");

    // Step 2: Extract structured appraisal fields
    System.out.println("Step 2: Extracting appraisal fields...");
    Map<String, Object> extractResponse = callExtractRun(dataUrl);
    String extractStatus = (String) extractResponse.get("status");
    
    if (!"PROCESSED".equals(extractStatus)) {
      throw new Exception("Extraction failed with status: " + extractStatus);
    }

    Map<String, Object> extractOutput = (Map<String, Object>) extractResponse.get("output");
    Map<String, Object> extractedData = (Map<String, Object>) extractOutput.get("value");

    // Step 3: Format and return results
    System.out.println("\n=== Appraisal Report Extraction Results ===");
    System.out.println(formatJson(extractedData));

    Map<String, Object> result = new HashMap<>();
    Map<String, Object> parsed = new HashMap<>();
    parsed.put("markdown", markdown.toString());
    parsed.put("chunkCount", chunks.size());
    result.put("parsed", parsed);
    result.put("extracted", extractedData);
    result.put("status", "success");
    
    return result;
  }

  private Map<String, Object> callParseRun(String dataUrl) throws Exception {
    String jsonBody = buildParseRunJson(dataUrl);
    return makeApiRequest("/v1/parseRuns", jsonBody);
  }

  private Map<String, Object> callExtractRun(String dataUrl) throws Exception {
    String jsonBody = buildExtractRunJson(dataUrl);
    return makeApiRequest("/v1/extractRuns", jsonBody);
  }

  private String buildParseRunJson(String dataUrl) {
    return "{"
        + "\"file\":{\"url\":\"" + escapeJson(dataUrl) + "\"},"
        + "\"config\":{"
        + "\"blockOptions\":{\"text\":{\"agentic\":{\"enabled\":true}}},"
        + "\"chunkingStrategy\":{\"type\":\"document\"}"
        + "}"
        + "}";
  }

  private String buildExtractRunJson(String dataUrl) {
    return "{"
        + "\"file\":{\"url\":\"" + escapeJson(dataUrl) + "\"},"
        + "\"config\":{"
        + "\"schema\":{"
        + "\"type\":\"object\","
        + "\"properties\":{"
        + "\"file_number\":{\"type\":[\"string\",\"null\"],\"description\":\"Unique identifier for the appraisal report\"},"
        + "\"property_address\":{\"type\":[\"string\",\"null\"],\"description\":\"Full street address of the subject property\"},"
        + "\"city\":{\"type\":[\"string\",\"null\"],\"description\":\"City where the property is located\"},"
        + "\"state\":{\"type\":[\"string\",\"null\"],\"description\":\"State abbreviation where the property is located\"},"
        + "\"zip_code\":{\"type\":[\"string\",\"null\"],\"description\":\"Postal zip code of the property\"},"
        + "\"county\":{\"type\":[\"string\",\"null\"],\"description\":\"County where the property is located\"},"
        + "\"borrower_name\":{\"type\":[\"string\",\"null\"],\"description\":\"Name of the borrower/applicant\"},"
        + "\"lender_name\":{\"type\":[\"string\",\"null\"],\"description\":\"Name of the lender/client institution\"},"
        + "\"assignment_type\":{\"type\":[\"string\",\"null\"],\"description\":\"Type of assignment: Purchase Transaction, Refinance Transaction, or Other\"},"
        + "\"market_value_opinion\":{\"type\":[\"string\",\"null\"],\"description\":\"Appraiser's opinion of the market value of the subject property\"},"
        + "\"annual_property_taxes\":{\"type\":[\"string\",\"null\"],\"description\":\"Annual real estate taxes in dollars\"},"
        + "\"hoa_fees\":{\"type\":[\"string\",\"null\"],\"description\":\"Annual or monthly HOA fees amount\"}"
        + "}"
        + "},"
        + "\"baseProcessor\":\"extraction_performance\","
        + "\"advancedOptions\":{"
        + "\"reviewAgent\":{\"enabled\":true},"
        + "\"advancedMultimodalEnabled\":true"
        + "}"
        + "}"
        + "}";
  }

  private Map<String, Object> makeApiRequest(String endpoint, String jsonBody) 
      throws IOException, InterruptedException {
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(API_BASE + endpoint))
        .header("Authorization", "Bearer " + API_KEY)
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
        .build();

    HttpResponse<String> response = httpClient.send(request, 
        HttpResponse.BodyHandlers.ofString());

    if (response.statusCode() >= 400) {
      throw new IOException("API request failed with status " + response.statusCode() 
          + ": " + response.body());
    }

    return parseJsonToMap(response.body());
  }

  private Map<String, Object> parseJsonToMap(String json) {
    // Simple JSON parser for the response structure
    Map<String, Object> result = new HashMap<>();
    json = json.trim();
    if (json.startsWith("{") && json.endsWith("}")) {
      json = json.substring(1, json.length() - 1);
      String[] pairs = json.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)");
      for (String pair : pairs) {
        int colonIndex = pair.indexOf(":");
        if (colonIndex > 0) {
          String key = pair.substring(0, colonIndex).trim().replaceAll("\"", "");
          String value = pair.substring(colonIndex + 1).trim();
          result.put(key, parseJsonValue(value));
        }
      }
    }
    return result;
  }

  private Object parseJsonValue(String value) {
    value = value.trim();
    if (value.startsWith("\"") && value.endsWith("\"")) {
      return value.substring(1, value.length() - 1);
    } else if (value.startsWith("[") && value.endsWith("]")) {
      return parseJsonArray(value);
    } else if (value.startsWith("{") && value.endsWith("}")) {
      return parseJsonToMap(value);
    } else if ("true".equals(value)) {
      return true;
    } else if ("false".equals(value)) {
      return false;
    } else if ("null".equals(value)) {
      return null;
    }
    return value;
  }

  private java.util.List<Object> parseJsonArray(String json) {
    java.util.List<Object> list = new java.util.ArrayList<>();
    json = json.substring(1, json.length() - 1).trim();
    if (!json.isEmpty()) {
      String[] items = json.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)");
      for (String item : items) {
        list.add(parseJsonValue(item.trim()));
      }
    }
    return list;
  }

  private String escapeJson(String value) {
    return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n");
  }

  private String formatJson(Map<String, Object> data) {
    StringBuilder sb = new StringBuilder("{\n");
    boolean first = true;
    for (Map.Entry<String, Object> entry : data.entrySet()) {
      if (!first) sb.append(",\n");
      sb.append("  \"").append(entry.getKey()).append("\": ");
      sb.append(formatJsonValue(entry.getValue()));
      first = false;
    }
    sb.append("\n}");
    return sb.toString();
  }

  private String formatJsonValue(Object value) {
    if (value == null) {
      return "null";
    } else if (value instanceof String) {
      return "\"" + value + "\"";
    } else if (value instanceof Map) {
      return formatJson((Map<String, Object>) value);
    }
    return value.toString();
  }
}
// This code uses the Extend REST API directly because Extend has no official Go SDK.
// It calls https://api.extend.ai endpoints with standard library net/http and encoding/json.

package main

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

const apiBase = "https://api.extend.ai"

type ExtendClient struct {
	token string
	http  *http.Client
}

func NewExtendClient(token string) *ExtendClient {
	return &ExtendClient{
		token: token,
		http:  &http.Client{Timeout: 30 * time.Second},
	}
}

func (c *ExtendClient) doRequest(method, endpoint string, body interface{}) ([]byte, error) {
	var reqBody io.Reader
	if body != nil {
		jsonData, err := json.Marshal(body)
		if err != nil {
			return nil, err
		}
		reqBody = bytes.NewBuffer(jsonData)
	}

	req, err := http.NewRequest(method, apiBase+endpoint, reqBody)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("API error: status %d, body: %s", resp.StatusCode, string(respBody))
	}

	return respBody, nil
}

type FileRef struct {
	URL string `json:"url"`
}

type BlockOptions struct {
	Text struct {
		Agentic struct {
			Enabled bool `json:"enabled"`
		} `json:"agentic"`
	} `json:"text"`
}

type ChunkingStrategy struct {
	Type string `json:"type"`
}

type ParseConfig struct {
	BlockOptions      BlockOptions      `json:"blockOptions"`
	ChunkingStrategy  ChunkingStrategy  `json:"chunkingStrategy"`
}

type ParseRequest struct {
	File   FileRef     `json:"file"`
	Config ParseConfig `json:"config"`
}

type Chunk struct {
	Content string `json:"content"`
}

type ParseOutput struct {
	Chunks []Chunk `json:"chunks"`
}

type ParseRun struct {
	Status string      `json:"status"`
	Output ParseOutput `json:"output"`
}

type AdvancedOptions struct {
	ReviewAgent struct {
		Enabled bool `json:"enabled"`
	} `json:"reviewAgent"`
	AdvancedMultimodalEnabled bool `json:"advancedMultimodalEnabled"`
}

type ExtractConfig struct {
	Schema                   json.RawMessage `json:"schema"`
	BaseProcessor            string          `json:"baseProcessor"`
	AdvancedOptions          AdvancedOptions `json:"advancedOptions"`
}

type ExtractRequest struct {
	File   FileRef       `json:"file"`
	Config ExtractConfig `json:"config"`
}

type ExtractOutput struct {
	Value map[string]interface{} `json:"value"`
}

type ExtractRun struct {
	Status string        `json:"status"`
	Output ExtractOutput `json:"output"`
}

func (c *ExtendClient) createParseRun(req ParseRequest) (string, error) {
	respData, err := c.doRequest("POST", "/parse-runs", req)
	if err != nil {
		return "", err
	}

	var result map[string]interface{}
	if err := json.Unmarshal(respData, &result); err != nil {
		return "", err
	}

	if id, ok := result["id"].(string); ok {
		return id, nil
	}
	return "", fmt.Errorf("no id in parse run response")
}

func (c *ExtendClient) getParseRun(id string) (*ParseRun, error) {
	respData, err := c.doRequest("GET", "/parse-runs/"+id, nil)
	if err != nil {
		return nil, err
	}

	var result ParseRun
	if err := json.Unmarshal(respData, &result); err != nil {
		return nil, err
	}
	return &result, nil
}

func (c *ExtendClient) pollParseRun(id string, maxWait time.Duration) (*ParseRun, error) {
	deadline := time.Now().Add(maxWait)
	for {
		run, err := c.getParseRun(id)
		if err != nil {
			return nil, err
		}

		if run.Status == "PROCESSED" || run.Status == "FAILED" {
			return run, nil
		}

		if time.Now().After(deadline) {
			return nil, fmt.Errorf("poll timeout")
		}

		time.Sleep(2 * time.Second)
	}
}

func (c *ExtendClient) createExtractRun(req ExtractRequest) (string, error) {
	respData, err := c.doRequest("POST", "/extract-runs", req)
	if err != nil {
		return "", err
	}

	var result map[string]interface{}
	if err := json.Unmarshal(respData, &result); err != nil {
		return "", err
	}

	if id, ok := result["id"].(string); ok {
		return id, nil
	}
	return "", fmt.Errorf("no id in extract run response")
}

func (c *ExtendClient) getExtractRun(id string) (*ExtractRun, error) {
	respData, err := c.doRequest("GET", "/extract-runs/"+id, nil)
	if err != nil {
		return nil, err
	}

	var result ExtractRun
	if err := json.Unmarshal(respData, &result); err != nil {
		return nil, err
	}
	return &result, nil
}

func (c *ExtendClient) pollExtractRun(id string, maxWait time.Duration) (*ExtractRun, error) {
	deadline := time.Now().Add(maxWait)
	for {
		run, err := c.getExtractRun(id)
		if err != nil {
			return nil, err
		}

		if run.Status == "PROCESSED" || run.Status == "FAILED" {
			return run, nil
		}

		if time.Now().After(deadline) {
			return nil, fmt.Errorf("poll timeout")
		}

		time.Sleep(2 * time.Second)
	}
}

type ParsedOutput struct {
	Markdown   string `json:"markdown"`
	ChunkCount int    `json:"chunkCount"`
}

type Result struct {
	Parsed    ParsedOutput           `json:"parsed"`
	Extracted map[string]interface{} `json:"extracted"`
	Status    string                 `json:"status"`
}

func processAppraisalReport(filePath string) (*Result, error) {
	client := NewExtendClient(os.Getenv("EXTEND_API_KEY"))

	// Read file and encode to base64 data URL
	fileContent, err := os.ReadFile(filePath)
	if err != nil {
		return nil, err
	}

	dataURL := fmt.Sprintf("data:application/octet-stream;base64,%s", base64.StdEncoding.EncodeToString(fileContent))

	fmt.Printf("Processing appraisal report: %s\n", filePath)

	// Step 1: Parse the URAR to markdown
	fmt.Println("Step 1: Parsing appraisal report to markdown...")

	parseReq := ParseRequest{
		File: FileRef{URL: dataURL},
		Config: ParseConfig{
			BlockOptions: BlockOptions{},
			ChunkingStrategy: ChunkingStrategy{
				Type: "document",
			},
		},
	}
	parseReq.Config.BlockOptions.Text.Agentic.Enabled = true

	parseID, err := client.createParseRun(parseReq)
	if err != nil {
		return nil, err
	}

	parseRun, err := client.pollParseRun(parseID, 5*time.Minute)
	if err != nil {
		return nil, err
	}

	if parseRun.Status != "PROCESSED" {
		return nil, fmt.Errorf("parse failed with status: %s", parseRun.Status)
	}

	// Collect parsed markdown
	var markdownParts []string
	for _, chunk := range parseRun.Output.Chunks {
		markdownParts = append(markdownParts, chunk.Content)
	}
	markdown := ""
	for i, part := range markdownParts {
		if i > 0 {
			markdown += "\n\n"
		}
		markdown += part
	}

	fmt.Printf("Parsed markdown length: %d characters\n", len(markdown))

	// Step 2: Extract structured appraisal fields
	fmt.Println("Step 2: Extracting appraisal fields...")

	schema := json.RawMessage(`{
		"type": "object",
		"properties": {
			"file_number": {
				"type": ["string", "null"],
				"description": "Unique identifier for the appraisal report"
			},
			"property_address": {
				"type": ["string", "null"],
				"description": "Full street address of the subject property"
			},
			"city": {
				"type": ["string", "null"],
				"description": "City where the property is located"
			},
			"state": {
				"type": ["string", "null"],
				"description": "State abbreviation where the property is located"
			},
			"zip_code": {
				"type": ["string", "null"],
				"description": "Postal zip code of the property"
			},
			"county": {
				"type": ["string", "null"],
				"description": "County where the property is located"
			},
			"borrower_name": {
				"type": ["string", "null"],
				"description": "Name of the borrower/applicant"
			},
			"lender_name": {
				"type": ["string", "null"],
				"description": "Name of the lender/client institution"
			},
			"assignment_type": {
				"type": ["string", "null"],
				"description": "Type of assignment: Purchase Transaction, Refinance Transaction, or Other"
			},
			"market_value_opinion": {
				"type": ["string", "null"],
				"description": "Appraiser's opinion of the market value of the subject property, e.g. $450,000"
			},
			"annual_property_taxes": {
				"type": ["string", "null"],
				"description": "Annual real estate taxes in dollars, e.g. $3,500 or $3,500/year"
			},
			"hoa_fees": {
				"type": ["string", "null"],
				"description": "Annual or monthly HOA fees amount, e.g. $2,400/year or $200/month"
			}
		}
	}`)

	extractReq := ExtractRequest{
		File: FileRef{URL: dataURL},
		Config: ExtractConfig{
			Schema:        schema,
			BaseProcessor: "extraction_performance",
		},
	}
	extractReq.Config.AdvancedOptions.ReviewAgent.Enabled = true
	extractReq.Config.AdvancedOptions.AdvancedMultimodalEnabled = true

	extractID, err := client.createExtractRun(extractReq)
	if err != nil {
		return nil, err
	}

	extractRun, err := client.pollExtractRun(extractID, 5*time.Minute)
	if err != nil {
		return nil, err
	}

	if extractRun.Status != "PROCESSED" {
		return nil, fmt.Errorf("extraction failed with status: %s", extractRun.Status)
	}

	// Step 3: Format and return results
	fmt.Println("\n=== Appraisal Report Extraction Results ===")
	jsonData, _ := json.MarshalIndent(extractRun.Output.Value, "", "  ")
	fmt.Println(string(jsonData))

	return &Result{
		Parsed: ParsedOutput{
			Markdown:   markdown,
			ChunkCount: len(parseRun.Output.Chunks),
		},
		Extracted: extractRun.Output.Value,
		Status:    "success",
	}, nil
}

func main() {
	filePath := "./appraisal_sample.pdf"
	if len(os.Args) > 1 {
		filePath = os.Args[1]
	}

	result, err := processAppraisalReport(filePath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Pipeline failed: %v\n", err)
		os.Exit(1)
	}

	fmt.Println("\nPipeline completed successfully.")
	fmt.Println("Extracted data ready for downstream systems.")
	fmt.Printf("\nFull result: %+v\n", result)
}
// Deploy the "Appraisal Report" pipeline to YOUR Extend account.
//
// The workflow below is fully self-contained — every EXTRACT/CLASSIFY/SPLIT
// step carries its extractor/classifier/splitter config INLINE, so this is a
// single API call. No processors to create or wire up beforehand.
// Idempotent: the created workflow id is cached in .extend/appraisal-report-qc-extraction.json,
// so re-running updates the existing workflow instead of duplicating it.
//
// Usage:
//   export EXTEND_API_KEY=sk_...   (from https://dashboard.extend.ai → API Keys)
//   npx tsx provision.ts
//
// Generated by doc1 (template: appraisal-report-qc-extraction).

import fs from "node:fs";
import path from "node:path";

const API = "https://api.extend.ai";
const VERSION = "2026-02-09";
const API_KEY = process.env.EXTEND_API_KEY;
if (!API_KEY) { console.error("Set EXTEND_API_KEY first."); process.exit(1); }

const STATE_DIR = path.join(process.cwd(), ".extend");
const STATE_FILE = path.join(STATE_DIR, "appraisal-report-qc-extraction.json");

type State = { workflowId?: string };
const state: State = fs.existsSync(STATE_FILE)
  ? JSON.parse(fs.readFileSync(STATE_FILE, "utf8"))
  : {};
function saveState() {
  fs.mkdirSync(STATE_DIR, { recursive: true });
  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}

async function api(method: string, pathName: string, body?: unknown) {
  const res = await fetch(API + pathName, {
    method,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "x-extend-api-version": VERSION,
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${method} ${pathName} failed (${res.status}): ${JSON.stringify(data).slice(0, 300)}`);
  return data;
}

// ── Workflow definition — extractor/classifier/splitter configs inline ──────
const WORKFLOW = {
  "name": "Appraisal Report Processing Pipeline",
  "steps": [
    {
      "name": "startTrigger1",
      "type": "TRIGGER",
      "next": [
        {
          "step": "parse1"
        }
      ]
    },
    {
      "name": "parse1",
      "type": "PARSE",
      "config": {
        "parseConfig": {
          "blockOptions": {
            "text": {
              "agentic": {
                "enabled": true
              }
            }
          },
          "chunkingStrategy": {
            "type": "document"
          }
        }
      },
      "next": [
        {
          "step": "extraction2"
        }
      ]
    },
    {
      "name": "extraction2",
      "type": "EXTRACT",
      "config": {
        "extractorConfig": {
          "schema": {
            "type": "object",
            "properties": {
              "city": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "City where the property is located"
              },
              "state": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "State abbreviation where the property is located"
              },
              "county": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "County where the property is located"
              },
              "hoa_fees": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Annual or monthly HOA fees amount"
              },
              "zip_code": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Postal zip code of the property"
              },
              "file_number": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Unique identifier for the appraisal report"
              },
              "lender_name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Name of the lender/client institution"
              },
              "borrower_name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Name of the borrower/applicant"
              },
              "assignment_type": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Type of assignment (Purchase Transaction, Refinance Transaction, Other)"
              },
              "property_address": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Full street address of the subject property"
              },
              "market_value_opinion": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Appraiser's opinion of the market value of the subject property"
              },
              "annual_property_taxes": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Annual real estate taxes in dollars"
              }
            }
          },
          "baseProcessor": "extraction_performance",
          "advancedOptions": {
            "reviewAgent": {
              "enabled": true
            },
            "advancedMultimodalEnabled": true
          }
        }
      }
    }
  ]
};

async function main() {
  console.log(`Deploying "${WORKFLOW.name}"…`);

  if (state.workflowId) {
    console.log(`✓ workflow already provisioned (${state.workflowId}) — updating steps`);
    await api("POST", `/workflows/${state.workflowId}`, { steps: WORKFLOW.steps });
  } else {
    // Reuse an existing workflow with the same name if one exists (e.g. a
    // previous run's state file was lost) instead of creating a duplicate.
    try {
      const list = await api("GET", `/workflows?name=${encodeURIComponent(WORKFLOW.name)}`);
      const items = (list.data ?? list.items ?? []) as Array<{ name?: string; id?: string }>;
      const existing = items.find((x) => x.name === WORKFLOW.name);
      if (existing?.id) {
        state.workflowId = existing.id; saveState();
        console.log(`✓ workflow "${WORKFLOW.name}" found in your account (${existing.id}) — updating steps`);
        await api("POST", `/workflows/${existing.id}`, { steps: WORKFLOW.steps });
      }
    } catch { /* lookup is best-effort; fall through to create */ }

    if (!state.workflowId) {
      const created = await api("POST", "/workflows", WORKFLOW);
      const wfId = created.id ?? created.workflow?.id;
      if (!wfId) throw new Error("Could not read created workflow id from response");
      state.workflowId = wfId; saveState();
      console.log(`+ created workflow (${wfId})`);
    }
  }

  // Deploy the current draft as a new version so the workflow is runnable —
  // best-effort: some accounts/plans may not require this explicit step.
  await api("POST", `/workflows/${state.workflowId}/versions`, {}).catch(() => {});

  console.log("\nDone. Run documents through it with:");
  console.log(`  POST ${API}/workflow_runs  { workflow: { id: "${state.workflowId}" }, file: { url: "https://…" } }`);
  console.log("Or open the workflow in the Extend dashboard to review and deploy it.");
}

main().catch((e) => { console.error(e.message ?? e); process.exit(1); });
import os
import json
import sys
from pathlib import Path
from extend_ai import Extend

API_KEY = os.environ.get("EXTEND_API_KEY")
if not API_KEY:
    print("Set EXTEND_API_KEY first.")
    sys.exit(1)

STATE_DIR = Path.cwd() / ".extend"
STATE_FILE = STATE_DIR / "appraisal-report-qc-extraction.json"

def load_state() -> dict:
    if STATE_FILE.exists():
        with open(STATE_FILE, "r") as f:
            return json.load(f)
    return {}

def save_state(state: dict) -> None:
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    with open(STATE_FILE, "w") as f:
        json.dump(state, f, indent=2)

WORKFLOW = {
    "name": "Appraisal Report Processing Pipeline",
    "steps": [
        {
            "name": "startTrigger1",
            "type": "TRIGGER",
            "next": [
                {
                    "step": "parse1"
                }
            ]
        },
        {
            "name": "parse1",
            "type": "PARSE",
            "config": {
                "parseConfig": {
                    "blockOptions": {
                        "text": {
                            "agentic": {
                                "enabled": True
                            }
                        }
                    },
                    "chunkingStrategy": {
                        "type": "document"
                    }
                }
            },
            "next": [
                {
                    "step": "extraction2"
                }
            ]
        },
        {
            "name": "extraction2",
            "type": "EXTRACT",
            "config": {
                "extractorConfig": {
                    "schema": {
                        "type": "object",
                        "properties": {
                            "city": {
                                "type": ["string", "null"],
                                "description": "City where the property is located"
                            },
                            "state": {
                                "type": ["string", "null"],
                                "description": "State abbreviation where the property is located"
                            },
                            "county": {
                                "type": ["string", "null"],
                                "description": "County where the property is located"
                            },
                            "hoa_fees": {
                                "type": ["string", "null"],
                                "description": "Annual or monthly HOA fees amount"
                            },
                            "zip_code": {
                                "type": ["string", "null"],
                                "description": "Postal zip code of the property"
                            },
                            "file_number": {
                                "type": ["string", "null"],
                                "description": "Unique identifier for the appraisal report"
                            },
                            "lender_name": {
                                "type": ["string", "null"],
                                "description": "Name of the lender/client institution"
                            },
                            "borrower_name": {
                                "type": ["string", "null"],
                                "description": "Name of the borrower/applicant"
                            },
                            "assignment_type": {
                                "type": ["string", "null"],
                                "description": "Type of assignment (Purchase Transaction, Refinance Transaction, Other)"
                            },
                            "property_address": {
                                "type": ["string", "null"],
                                "description": "Full street address of the subject property"
                            },
                            "market_value_opinion": {
                                "type": ["string", "null"],
                                "description": "Appraiser's opinion of the market value of the subject property"
                            },
                            "annual_property_taxes": {
                                "type": ["string", "null"],
                                "description": "Annual real estate taxes in dollars"
                            }
                        }
                    },
                    "baseProcessor": "extraction_performance",
                    "advancedOptions": {
                        "reviewAgent": {
                            "enabled": True
                        },
                        "advancedMultimodalEnabled": True
                    }
                }
            }
        }
    ]
}

async def main():
    client = Extend(token=API_KEY)
    state = load_state()
    
    print(f"Deploying \"{WORKFLOW['name']}\"…")
    
    if state.get("workflowId"):
        workflow_id = state["workflowId"]
        print(f"✓ workflow already provisioned ({workflow_id}) — updating steps")
        await client.workflows.update(id=workflow_id, steps=WORKFLOW["steps"])
    else:
        # Try to find existing workflow with the same name
        existing_workflow_id = None
        try:
            workflows_response = await client.workflows.list(name=WORKFLOW["name"])
            items = workflows_response.data if hasattr(workflows_response, 'data') else (workflows_response.items if hasattr(workflows_response, 'items') else [])
            for item in items:
                if item.get("name") == WORKFLOW["name"]:
                    existing_workflow_id = item.get("id")
                    break
        except Exception:
            pass  # lookup is best-effort; fall through to create
        
        if existing_workflow_id:
            state["workflowId"] = existing_workflow_id
            save_state(state)
            print(f"✓ workflow \"{WORKFLOW['name']}\" found in your account ({existing_workflow_id}) — updating steps")
            await client.workflows.update(id=existing_workflow_id, steps=WORKFLOW["steps"])
        else:
            created = await client.workflows.create(**WORKFLOW)
            workflow_id = created.id if hasattr(created, 'id') else (created.workflow.id if hasattr(created, 'workflow') else None)
            if not workflow_id:
                raise Exception("Could not read created workflow id from response")
            state["workflowId"] = workflow_id
            save_state(state)
            print(f"+ created workflow ({workflow_id})")
    
    # Deploy the current draft as a new version
    try:
        await client.workflows.create_version(id=state["workflowId"])
    except Exception:
        pass  # best-effort: some accounts/plans may not require this
    
    print("\nDone. Run documents through it with:")
    print(f"  POST https://api.extend.ai/workflow_runs  {{ \"workflow\": {{ \"id\": \"{state['workflowId']}\" }}, \"file\": {{ \"url\": \"https://…\" }} }}")
    print("Or open the workflow in the Extend dashboard to review and deploy it.")

if __name__ == "__main__":
    import asyncio
    try:
        asyncio.run(main())
    except Exception as e:
        print(str(e), file=sys.stderr)
        sys.exit(1)
// Extend REST API client — calls the real endpoints directly.
// Extend has no official Java SDK yet; this uses java.net.http.HttpClient with zero external dependencies.

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;

public class ProvisionAppraisalReport {
  private static final String API = "https://api.extend.ai";
  private static final String VERSION = "2026-02-09";
  private static final String API_KEY = System.getenv("EXTEND_API_KEY");
  private static final Path STATE_DIR = Paths.get(System.getProperty("user.dir"), ".extend");
  private static final Path STATE_FILE = STATE_DIR.resolve("appraisal-report-qc-extraction.json");

  static class State {
    String workflowId;
  }

  private static final State state = loadState();

  public static void main(String[] args) {
    if (API_KEY == null || API_KEY.isEmpty()) {
      System.err.println("Set EXTEND_API_KEY first.");
      System.exit(1);
    }

    try {
      mainAsync();
    } catch (Exception e) {
      System.err.println(e.getMessage() != null ? e.getMessage() : e.toString());
      System.exit(1);
    }
  }

  static void mainAsync() throws Exception {
    Map<String, Object> workflow = buildWorkflow();
    String workflowName = (String) workflow.get("name");

    System.out.println("Deploying \"" + workflowName + "\"…");

    if (state.workflowId != null && !state.workflowId.isEmpty()) {
      System.out.println("✓ workflow already provisioned (" + state.workflowId + ") — updating steps");
      Map<String, Object> updateBody = new HashMap<>();
      updateBody.put("steps", workflow.get("steps"));
      api("POST", "/workflows/" + state.workflowId, updateBody);
    } else {
      // Try to find an existing workflow with the same name.
      try {
        Map<String, Object> list = api("GET", "/workflows?name=" + URLEncoder.encode(workflowName, StandardCharsets.UTF_8), null);
        List<?> items = (List<?>) list.getOrDefault("data", list.getOrDefault("items", new ArrayList<>()));
        Map<String, Object> existing = null;
        for (Object item : items) {
          if (item instanceof Map) {
            Map<String, Object> m = (Map<String, Object>) item;
            if (workflowName.equals(m.get("name"))) {
              existing = m;
              break;
            }
          }
        }
        if (existing != null && existing.get("id") != null) {
          state.workflowId = (String) existing.get("id");
          saveState();
          System.out.println("✓ workflow \"" + workflowName + "\" found in your account (" + state.workflowId + ") — updating steps");
          Map<String, Object> updateBody = new HashMap<>();
          updateBody.put("steps", workflow.get("steps"));
          api("POST", "/workflows/" + state.workflowId, updateBody);
        }
      } catch (Exception e) {
        // lookup is best-effort; fall through to create
      }

      if (state.workflowId == null || state.workflowId.isEmpty()) {
        Map<String, Object> created = api("POST", "/workflows", workflow);
        String wfId = (String) created.get("id");
        if (wfId == null) {
          Map<String, Object> wfObj = (Map<String, Object>) created.get("workflow");
          if (wfObj != null) {
            wfId = (String) wfObj.get("id");
          }
        }
        if (wfId == null) {
          throw new Exception("Could not read created workflow id from response");
        }
        state.workflowId = wfId;
        saveState();
        System.out.println("+ created workflow (" + wfId + ")");
      }
    }

    // Deploy the current draft as a new version — best-effort.
    try {
      api("POST", "/workflows/" + state.workflowId + "/versions", new HashMap<>());
    } catch (Exception e) {
      // ignore
    }

    System.out.println("\nDone. Run documents through it with:");
    System.out.println("  POST " + API + "/workflow_runs  { workflow: { id: \"" + state.workflowId + "\" }, file: { url: \"https://…\" } }");
    System.out.println("Or open the workflow in the Extend dashboard to review and deploy it.");
  }

  static Map<String, Object> api(String method, String pathName, Map<String, Object> body) throws Exception {
    HttpClient client = HttpClient.newHttpClient();
    String url = API + pathName;
    HttpRequest.Builder builder = HttpRequest.newBuilder()
        .uri(URI.create(url))
        .method(method, body != null ? HttpRequest.BodyPublishers.ofString(toJson(body)) : HttpRequest.BodyPublishers.noBody())
        .header("Authorization", "Bearer " + API_KEY)
        .header("x-extend-api-version", VERSION);

    if (body != null) {
      builder.header("Content-Type", "application/json");
    }

    HttpRequest request = builder.build();
    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

    Map<String, Object> data = parseJson(response.body());
    if (response.statusCode() < 200 || response.statusCode() >= 300) {
      String errorMsg = toJson(data);
      if (errorMsg.length() > 300) {
        errorMsg = errorMsg.substring(0, 300);
      }
      throw new Exception(method + " " + pathName + " failed (" + response.statusCode() + "): " + errorMsg);
    }

    return data;
  }

  static Map<String, Object> buildWorkflow() {
    Map<String, Object> workflow = new LinkedHashMap<>();
    workflow.put("name", "Appraisal Report Processing Pipeline");
    workflow.put("steps", buildSteps());
    return workflow;
  }

  static List<Map<String, Object>> buildSteps() {
    List<Map<String, Object>> steps = new ArrayList<>();

    // Trigger step
    Map<String, Object> triggerStep = new LinkedHashMap<>();
    triggerStep.put("name", "startTrigger1");
    triggerStep.put("type", "TRIGGER");
    List<Map<String, Object>> triggerNext = new ArrayList<>();
    Map<String, Object> triggerNextItem = new HashMap<>();
    triggerNextItem.put("step", "parse1");
    triggerNext.add(triggerNextItem);
    triggerStep.put("next", triggerNext);
    steps.add(triggerStep);

    // Parse step
    Map<String, Object> parseStep = new LinkedHashMap<>();
    parseStep.put("name", "parse1");
    parseStep.put("type", "PARSE");
    Map<String, Object> parseConfig = new LinkedHashMap<>();
    Map<String, Object> parseConfigInner = new LinkedHashMap<>();
    Map<String, Object> blockOptions = new LinkedHashMap<>();
    Map<String, Object> textOptions = new LinkedHashMap<>();
    Map<String, Object> agenticOptions = new HashMap<>();
    agenticOptions.put("enabled", true);
    textOptions.put("agentic", agenticOptions);
    blockOptions.put("text", textOptions);
    parseConfigInner.put("blockOptions", blockOptions);
    Map<String, Object> chunkingStrategy = new HashMap<>();
    chunkingStrategy.put("type", "document");
    parseConfigInner.put("chunkingStrategy", chunkingStrategy);
    parseConfig.put("parseConfig", parseConfigInner);
    parseStep.put("config", parseConfig);
    List<Map<String, Object>> parseNext = new ArrayList<>();
    Map<String, Object> parseNextItem = new HashMap<>();
    parseNextItem.put("step", "extraction2");
    parseNext.add(parseNextItem);
    parseStep.put("next", parseNext);
    steps.add(parseStep);

    // Extract step
    Map<String, Object> extractStep = new LinkedHashMap<>();
    extractStep.put("name", "extraction2");
    extractStep.put("type", "EXTRACT");
    Map<String, Object> extractConfig = new LinkedHashMap<>();
    Map<String, Object> extractorConfig = new LinkedHashMap<>();
    extractorConfig.put("schema", buildSchema());
    extractorConfig.put("baseProcessor", "extraction_performance");
    Map<String, Object> advancedOptions = new LinkedHashMap<>();
    Map<String, Object> reviewAgent = new HashMap<>();
    reviewAgent.put("enabled", true);
    advancedOptions.put("reviewAgent", reviewAgent);
    advancedOptions.put("advancedMultimodalEnabled", true);
    extractorConfig.put("advancedOptions", advancedOptions);
    extractConfig.put("extractorConfig", extractorConfig);
    extractStep.put("config", extractConfig);
    steps.add(extractStep);

    return steps;
  }

  static Map<String, Object> buildSchema() {
    Map<String, Object> schema = new LinkedHashMap<>();
    schema.put("type", "object");

    Map<String, Object> properties = new LinkedHashMap<>();
    addProperty(properties, "city", "City where the property is located");
    addProperty(properties, "state", "State abbreviation where the property is located");
    addProperty(properties, "county", "County where the property is located");
    addProperty(properties, "hoa_fees", "Annual or monthly HOA fees amount");
    addProperty(properties, "zip_code", "Postal zip code of the property");
    addProperty(properties, "file_number", "Unique identifier for the appraisal report");
    addProperty(properties, "lender_name", "Name of the lender/client institution");
    addProperty(properties, "borrower_name", "Name of the borrower/applicant");
    addProperty(properties, "assignment_type", "Type of assignment (Purchase Transaction, Refinance Transaction, Other)");
    addProperty(properties, "property_address", "Full street address of the subject property");
    addProperty(properties, "market_value_opinion", "Appraiser's opinion of the market value of the subject property");
    addProperty(properties, "annual_property_taxes", "Annual real estate taxes in dollars");

    schema.put("properties", properties);
    return schema;
  }

  static void addProperty(Map<String, Object> properties, String name, String description) {
    Map<String, Object> prop = new LinkedHashMap<>();
    prop.put("type", Arrays.asList("string", "null"));
    prop.put("description", description);
    properties.put(name, prop);
  }

  static State loadState() {
    State s = new State();
    if (Files.exists(STATE_FILE)) {
      try {
        String content = Files.readString(STATE_FILE);
        Map<String, Object> map = parseJson(content);
        if (map.get("workflowId") instanceof String) {
          s.workflowId = (String) map.get("workflowId");
        }
      } catch (IOException e) {
        // ignore
      }
    }
    return s;
  }

  static void saveState() throws IOException {
    Files.createDirectories(STATE_DIR);
    Map<String, Object> data = new HashMap<>();
    if (state.workflowId != null) {
      data.put("workflowId", state.workflowId);
    }
    Files.writeString(STATE_FILE, toJson(data));
  }

  static String toJson(Map<String, Object> map) {
    StringBuilder sb = new StringBuilder();
    toJsonValue(map, sb);
    return sb.toString();
  }

  static void toJsonValue(Object obj, StringBuilder sb) {
    if (obj == null) {
      sb.append("null");
    } else if (obj instanceof String) {
      sb.append("\"").append(escapeJson((String) obj)).append("\"");
    } else if (obj instanceof Boolean) {
      sb.append(obj.toString());
    } else if (obj instanceof Number) {
      sb.append(obj.toString());
    } else if (obj instanceof List) {
      sb.append("[");
      List<?> list = (List<?>) obj;
      for (int i = 0; i < list.size(); i++) {
        if (i > 0) sb.append(",");
        toJsonValue(list.get(i), sb);
      }
      sb.append("]");
    } else if (obj instanceof Map) {
      sb.append("{");
      Map<?, ?> map = (Map<?, ?>) obj;
      boolean first = true;
      for (Map.Entry<?, ?> e : map.entrySet()) {
        if (!first) sb.append(",");
        first = false;
        sb.append("\"").append(escapeJson(e.getKey().toString())).append("\":");
        toJsonValue(e.getValue(), sb);
      }
      sb.append("}");
    } else {
      sb.append("\"").append(escapeJson(obj.toString())).append("\"");
    }
  }

  static String escapeJson(String s) {
    return s.replace("\\", "\\\\")
        .replace("\"", "\\\"")
        .replace("\n", "\\n")
        .replace("\r", "\\r")
        .replace("\t", "\\t");
  }

  static Map<String, Object> parseJson(String json) {
    json = json.trim();
    if (json.startsWith("{")) {
      return parseJsonObject(json, 0)[0];
    }
    return new HashMap<>();
  }

  static Object[] parseJsonObject(String json, int start) {
    Map<String, Object> obj = new LinkedHashMap<>();
    int i = start + 1;
    while (i < json.length()) {
      char c = json.charAt(i);
      if (c == '}') {
        return new Object[]{obj, i + 1};
      }
      if (c == ',') {
        i++;
        continue;
      }
      if (c == ':') {
        i++;
        continue;
      }
      if (Character.isWhitespace(c)) {
        i++;
        continue;
      }
      if (c == '"') {
        Object[] keyResult = parseJsonString(json, i);
        String key = (String) keyResult[0];
        i = (int) keyResult[1];
        while (i < json.length() && json.charAt(i) != ':') i++;
        i++;
        while (i < json.length() && Character.isWhitespace(json.charAt(i))) i++;
        Object[] valResult = parseJsonValue(json, i);
        obj.put(key, valResult[0]);
        i = (int) valResult[1];
      } else {
        i++;
      }
    }
    return new Object[]{obj, json.length()};
  }

  static Object[] parseJsonArray(String json, int start) {
    List<Object> arr = new ArrayList<>();
    int i = start + 1;
    while (i < json.length()) {
      char c = json.charAt(i);
      if (c == ']') {
        return new Object[]{arr, i + 1};
      }
      if (c == ',') {
        i++;
        continue;
      }
      if (Character.isWhitespace(c)) {
        i++;
        continue;
      }
      Object[] valResult = parseJsonValue(json, i);
      arr.add(valResult[0]);
      i = (int) valResult[1];
    }
    return new Object[]{arr, json.length()};
  }

  static Object[] parseJsonValue(String json, int start) {
    while (start < json.length() && Character.isWhitespace(json.charAt(start))) start++;
    if (start >= json.length()) return new Object[]{null, start};

    char c = json.charAt(start);
    if (c == '"') {
      return parseJsonString(json, start);
    } else if (c == '{') {
      return parseJsonObject(json, start);
    } else if (c == '[') {
      return parseJsonArray(json, start);
    } else if (c == 't' || c == 'f') {
      if (json.startsWith("true", start)) return new Object[]{true, start + 4};
      if (json.startsWith("false", start)) return new Object[]{false, start + 5};
    } else if (c == 'n') {
      if (json.startsWith("null", start)) return new Object[]{null, start + 4};
    } else if (Character.isDigit(c) || c == '-') {
      int end = start;
      while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '.' || json.charAt(end) == '-' || json.charAt(end) == 'e' || json.charAt(end) == 'E' || json.charAt(end) == '+')) {
        end++;
      }
      String numStr = json.substring(start, end);
      try {
        if (numStr.contains(".")) {
          return new Object[]{Double.parseDouble(numStr), end};
        } else {
          return new Object[]{Long.parseLong(numStr), end};
        }
      } catch (Exception e) {
        return new Object[]{numStr, end};
      }
    }
    return new Object[]{null, start};
  }

  static Object[] parseJsonString(String json, int start) {
    StringBuilder sb = new StringBuilder();
    int i = start + 1;
    while (i < json.length()) {
      char c = json.charAt(i);
      if (c == '"') {
        return new Object[]{sb.toString(), i + 1};
      }
      if (c == '\\' && i + 1 < json.length()) {
        i++;
        char next = json.charAt(i);
        if (next == 'n') sb.append('\n');
        else if (next == 't') sb.append('\t');
        else if (next == 'r') sb.append('\r');
        else if (next == '"') sb.append('"');
        else if (next == '\\') sb.append('\\');
        else sb.append(next);
      } else {
        sb.append(c);
      }
      i++;
    }
    return new Object[]{sb.toString(), i};
  }
}
```go
// This provisioning script calls Extend's REST API directly because Extend has no official Go SDK yet.
// Deploy the "Appraisal Report" pipeline to YOUR Extend account.
//
// The workflow below is fully self-contained — every EXTRACT/CLASSIFY/SPLIT
// step carries its extractor/classifier/splitter config INLINE, so this is a
// single API call. No processors to create or wire up beforehand.
// Idempotent: the created workflow id is cached in .extend/appraisal-report-qc-extraction.json,
// so re-running updates the existing workflow instead of duplicating it.
//
// Usage:
//   export EXTEND_API_KEY=sk_...   (from https://dashboard.extend.ai → API Keys)
//   go run provision.go
//
// Generated by doc1 (template: appraisal-report-qc-extraction).

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
)

const (
	API     = "https://api.extend.ai"
	VERSION = "2026-02-09"
)

var (
	apiKey   string
	stateDir string
	stateFile string
)

type State struct {
	WorkflowID string `json:"workflowId,omitempty"`
}

var state State

func init() {
	apiKey = os.Getenv("EXTEND_API_KEY")
	if apiKey == "" {
		fmt.Fprintf(os.Stderr, "Set EXTEND_API_KEY first.\n")
		os.Exit(1)
	}

	cwd, err := os.Getwd()
	if err != nil {
		fmt.Fprintf(os.Stderr, "Failed to get working directory: %v\n", err)
		os.Exit(1)
	}
	stateDir = filepath.Join(cwd, ".extend")
	stateFile = filepath.Join(stateDir, "appraisal-report-qc-extraction.json")

	loadState()
}

func loadState() {
	data, err := os.ReadFile(stateFile)
	if err == nil {
		json.Unmarshal(data, &state)
	}
}

func saveState() error {
	if err := os.MkdirAll(stateDir, 0755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(state, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(stateFile, data, 0644)
}

func apiCall(method, pathName string, body interface{}) (map[string]interface{}, error) {
	var reqBody io.Reader
	if body != nil {
		bodyBytes, err := json.Marshal(body)
		if err != nil {
			return nil, err
		}
		reqBody = bytes.NewReader(bodyBytes)
	}

	req, err := http.NewRequest(method, API+pathName, reqBody)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
	req.Header.Set("x-extend-api-version", VERSION)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	var data map[string]interface{}
	json.Unmarshal(respBody, &data)

	if resp.StatusCode >= 400 {
		errMsg := string(respBody)
		if len(errMsg) > 300 {
			errMsg = errMsg[:300]
		}
		return nil, fmt.Errorf("%s %s failed (%d): %s", method, pathName, resp.StatusCode, errMsg)
	}

	return data, nil
}

var workflow = map[string]interface{}{
	"name": "Appraisal Report Processing Pipeline",
	"steps": []map[string]interface{}{
		{
			"name": "startTrigger1",
			"type": "TRIGGER",
			"next": []map[string]interface{}{
				{
					"step": "parse1",
				},
			},
		},
		{
			"name": "parse1",
			"type": "PARSE",
			"config": map[string]interface{}{
				"parseConfig": map[string]interface{}{
					"blockOptions": map[string]interface{}{
						"text": map[string]interface{}{
							"agentic": map[string]interface{}{
								"enabled": true,
							},
						},
					},
					"chunkingStrategy": map[string]interface{}{
						"type": "document",
					},
				},
			},
			"next": []map[string]interface{}{
				{
					"step": "extraction2",
				},
			},
		},
		{
			"name": "extraction2",
			"type": "EXTRACT",
			"config": map[string]interface{}{
				"extractorConfig": map[string]interface{}{
					"schema": map[string]interface{}{
						"type": "object",
						"properties": map[string]interface{}{
							"city": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "City where the property is located",
							},
							"state": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "State abbreviation where the property is located",
							},
							"county": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "County where the property is located",
							},
							"hoa_fees": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "Annual or monthly HOA fees amount",
							},
							"zip_code": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "Postal zip code of the property",
							},
							"file_number": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "Unique identifier for the appraisal report",
							},
							"lender_name": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "Name of the lender/client institution",
							},
							"borrower_name": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "Name of the borrower/applicant",
							},
							"assignment_type": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "Type of assignment (Purchase Transaction, Refinance Transaction, Other)",
							},
							"property_address": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "Full street address of the subject property",
							},
							"market_value_opinion": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "Appraiser's opinion of the market value of the subject property",
							},
							"annual_property_taxes": map[string]interface{}{
								"type":        []interface{}{"string", "null"},
								"description": "Annual real estate taxes in dollars",
							},
						},
					},
					"baseProcessor": "extraction_performance",
					"advancedOptions": map[string]interface{}{
						"reviewAgent": map[string]interface{}{
							"enabled": true,
						},
						"advancedMultimodalEnabled": true,
					},
				},
			},
		},
	},
}

func main() {
	workflowName := workflow["name"].(string)
	fmt.Printf("Deploying \"%s\"…\n", workflowName)

	if state.WorkflowID != "" {
		fmt.Printf("✓ workflow already provisioned (%s) — updating steps\n", state.WorkflowID)
		_, err := apiCall("POST", fmt.Sprintf("/workflows/%s", state.WorkflowID), map[string]interface{}{
			"steps": workflow["steps"],
		})
		if err != nil {
			fmt.Fprintf(os.Stderr, "%v\n", err)
			os.Exit(1)
		}
	} else {
		// Reuse an existing workflow with the same name if one exists
		found := false
		listURL := fmt.Sprintf("/workflows?name=%s", url.QueryEscape(workflowName))
		listResp, err := apiCall("GET", listURL, nil)
		if err == nil {
			var items []map[string]interface{}
			if data, ok := listResp["data"]; ok {
				if arr, ok := data.([]interface{}); ok {
					for _, item := range arr {
						if m, ok := item.(map[string]interface{}); ok {
							items = append(items, m)
						}
					}
				}
			} else if data, ok := listResp["items"]; ok {
				if arr, ok := data.([]interface{}); ok {
					for _, item := range arr {
						if m, ok := item.(map[string]interface{}); ok {
							items = append(items, m)
						}
					}
				}
			}

			for _, item := range items {
				if name, ok := item["name"].(string); ok && name == workflowName {
					if id, ok := item["id"].(string); ok {
						state.WorkflowID = id
						found = true
						break
					}
				}
			}

			if found {
				saveState()
				fmt.Printf("✓ workflow \"%s\" found in your account (%s) — updating steps\n", workflowName, state.WorkflowID)
				_, err := apiCall("POST", fmt.Sprintf("/workflows/%s", state.WorkflowID), map[string]interface{}{
					"steps": workflow["steps"],
				})
				if err != nil {
					fmt.Fprintf(os.Stderr, "%v\n", err)
					os.Exit(1)
				}
			}
		}

		if !found {
			created, err := apiCall("POST", "/workflows", workflow)
			if err != nil {
				fmt.Fprintf(os.Stderr, "%v\n", err)
				os.Exit(1)
			}

			wfID := ""
			if id, ok := created["id"].(string); ok {
				wfID = id
			} else if workflowData, ok := created["workflow"].(map[string]interface{}); ok {
				if id, ok := workflowData["id"].(string); ok {
					wfID = id
				}
			}

			if wfID == "" {
				fmt.Fprintf(os.Stderr, "Could not read created workflow id from response\n")
				os.Exit(1)
			}

			state.WorkflowID = wfID
			saveState()
			fmt.Printf("+ created workflow (%s)\n", wfID)
		}
	}

	// Deploy the current draft as a new version so the workflow is runnable
	apiCall("POST", fmt.Sprintf("/workflows/%s/versions", state.WorkflowID), map[string]interface{}{})

	fmt.Println("\nDone. Run documents through it with:")
	fmt.Printf("  POST %s/workflow_runs  { workflow: { id: \"%s\" }, file: { url: \"https://…\" } }\n", API, state.WorkflowID)
	fmt.Println("Or open the workflow in the Extend dashboard to review and deploy it.")
}
```

Frequently Asked Questions (FAQ)

Use async (`parseRuns.createAndPoll`) for any multi-page appraisal (typically 15–50 pages) since sync Parse times out over ~10 pages; async also handles complex layouts and handwritten adjustments more reliably. For real-time APIs, call async in the background and store results in your database.
Break the comparables table into a structured array schema with explicit field descriptions (e.g., "Sale price in dollars, numeric only"), parse in `agentic_ocr` mode to handle table borders and faded text, and include a second verification step—extract the raw markdown first, then use an LLM to validate numeric consistency across line items.
Always use `agentic_ocr` mode (not `light`) since it's trained on scans and handwriting; then add a post-extraction review step where you flag any field with `confidence < 0.8` for human review.
Tags
Real Estate AppraisalProperty ValuationMortgageURAR FormNeighborhood Analysis
About this template

This template extracts information from Uniform Residential Appraisal Report (URAR), capturing comprehensive property valuation data for lenders and clients. It includes borrower information, property characteristics, neighborhood analysis, and market value opinions required for mortgage refinancing and purchase transactions.

Document formats
  • PDF
  • Images & Scans
Requirements
  • Long tables
  • Checkboxes & Strikethroughs
  • Complex layouts