Back to Intelligence Hub
Python Tutorial20 Min Build • Real Estate AI

Let AI Create
Offering Memorandums
Automatically

Brokers spend 6–20 hours assembling OMs. Build a Python pipeline that generates investor-ready, multi-page Offering Memorandums — complete with AI narratives, financial charts, and brokerage branding — in under 30 seconds.

6–20h
Manual Assembly Time
< 30s
AI Generation Time
10–20
OM Pages Output
~$150
Monthly Infra Cost
What We Build

A Complete OM Generation Pipeline

By the end of this guide, you will have a fully automated system that accepts structured property inputs and produces a professionally branded, multi-page PDF Offering Memorandum ready to send to investors.

Generates executive summary and narrative with GPT-4
Computes financial metrics (cap rate, NOI, rent roll) in Python
Creates tenant income pie charts with Matplotlib
Builds multi-page HTML template with Jinja2
Exports pixel-perfect PDF via WeasyPrint
AI Workflow
Property Data + Images
LLM Narrative Generation
Financial Analysis + Charts
Multi-Page HTML Template
PDF Export (10–20 Pages)

Output: Investor-Ready PDF OM

System Architecture

The Full Pipeline Architecture

This modular architecture lets you swap components (GPT-4 → Claude, WeasyPrint → Puppeteer) without touching the pipeline logic.

Scalable

Handle one OM or thousands per month with the same pipeline.

Brandable

Inject any brokerage logo, color scheme, and boilerplate.

CRM-Ready

Accepts inputs from REST APIs, databases, or flat files.

Production-Proven

The same approach AxcelerateAI uses for enterprise clients.

Tech Stack

Tools & Libraries

ToolPurpose
PythonMain backend & orchestration
PandasFinancial analysis & rent roll
OpenAI API (GPT-4)AI narrative text generation
Jinja2HTML document templates
MatplotlibChart & graph generation
WeasyPrintHTML → PDF rendering
PillowImage resizing & normalization

Install everything in one command:

bash
pip install openai pandas matplotlib jinja2 weasyprint pillow
1
Step 1

Define Your Property Input Schema

The schema is the single source of truth for your entire pipeline. It includes property info, images, branding, and financial data. In production, this is populated automatically via a CRM API.

python
property_data = {
    "property_name": "Midtown Office Tower",
    "location":      "Dallas, TX",
    "price":         19_500_000,
    "noi":           1_240_000,
    "cap_rate":      6.4,
    "year_built":    2015,
    "square_feet":   85_000,

    "images": [
        "images/property_front.jpg",
        "images/lobby.jpg",
        "images/aerial.jpg"
    ],

    "broker_logo": "images/company_logo.png",

    "tenants": [
        {"name": "TechCorp", "rent": 32_000},
        {"name": "FinServe", "rent": 27_000},
    ]
}

Production tip: In an enterprise setup, this dictionary is populated automatically via your CRM's REST API — eliminating all manual data entry.

2
Step 2

Generate Narrative Sections with AI

GPT-4 writes the executive summary, investment highlights, and location description using structured property data. Grounding prompts in facts eliminates hallucinations.

python
from openai import OpenAI

client = OpenAI()

def generate_executive_summary(data: dict) -> str:
    prompt = f"""
    Write a professional executive summary for a commercial
    real estate Offering Memorandum.

    Property: {data["property_name"]}
    Location: {data["location"]}
    NOI: ${data["noi"]:,}
    Cap Rate: {data["cap_rate"]}%
    Year Built: {data["year_built"]}
    Square Feet: {data["square_feet"]:,}

    Tone: authoritative, investor-focused, concise.
    Length: 3 paragraphs maximum.
    """
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}]
    )
    return response.choices[0].message.content

summary = generate_executive_summary(property_data)
Output

Midtown Office Tower presents a compelling investment opportunity in the Dallas office market. Built in 2015 to institutional standards, the property offers modern amenities in one of Dallas's most active commercial corridors.

With a stabilized Net Operating Income of $1,240,000 and a market cap rate of 6.4%, this asset offers exceptional risk-adjusted returns...

3
Step 3

Compute Financial Tables with Pandas

Never ask an LLM to do arithmetic. Compute all financial metrics programmatically in Pandas — it ensures accuracy and produces DataFrames that inject cleanly into HTML templates.

python
import pandas as pd

def build_rent_roll(data: dict) -> tuple[pd.DataFrame, dict]:
    df = pd.DataFrame(data["tenants"])
    df["rent_annual"] = df["rent"] * 12
    df["pct_of_total"] = (df["rent"] / df["rent"].sum() * 100).round(1)

    metrics = {
        "total_monthly_rent": df["rent"].sum(),
        "total_annual_rent":  df["rent_annual"].sum(),
        "cap_rate_computed":  round(data["noi"] / data["price"] * 100, 2),
        "price_per_sf":       round(data["price"] / data["square_feet"], 2),
    }
    return df, metrics

rent_roll_df, metrics = build_rent_roll(property_data)
print(rent_roll_df)
print(metrics)
Output
         name   rent  rent_annual  pct_of_total
0     TechCorp  32000       384000          54.2
1     FinServe  27000       324000          45.8

{'total_monthly_rent': 59000, 'cap_rate_computed': 6.36, 'price_per_sf': 229.41}
4
Step 4

Generate Investor Charts

Charts dramatically improve investor readability. Matplotlib renders a donut chart per tenant and saves it as an image file, which is then embedded into the OM template.

python
import matplotlib.pyplot as plt

def generate_tenant_chart(df: pd.DataFrame, output_path: str = "tenant_chart.png"):
    fig, ax = plt.subplots(figsize=(7, 7))
    colors = ["#1e3a5f", "#2563eb", "#60a5fa"]

    wedges, texts, autotexts = ax.pie(
        df["rent"],
        labels=df["name"],
        autopct="%1.1f%%",
        colors=colors,
        startangle=90,
        wedgeprops=dict(width=0.6)   # Donut style
    )
    ax.set_title("Tenant Income Distribution", fontsize=16, fontweight="bold")
    fig.savefig(output_path, dpi=150, bbox_inches="tight", transparent=True)
    plt.close()
    return output_path

chart_path = generate_tenant_chart(rent_roll_df)

Pro tip: Use transparent=True so the chart blends seamlessly into both dark and light OM templates.

5
Step 5

Build the Multi-Page HTML Template

Jinja2 renders your data into a professional multi-page HTML document. CSS page-break rules ensure each section prints on its own dedicated page.

Page Structure

Pg 1Cover Page
Pg 2Exec. Summary
Pg 3Property Overview
Pg 4Investment Highlights
Pg 5Location Analysis
Pg 6Financial Overview
Pg 7Rent Roll Chart
Pg 8Image Gallery
Pg 9Broker Contact
html
{# om_template.html #}
<html>
<head>
  <style>
    .page { page-break-after: always; padding: 60px; }
    .cover { background: #1e3a5f; color: white; text-align: center; }
    .hero-image { width: 100%; border-radius: 12px; margin-top: 30px; }
  </style>
</head>
<body>

  <!-- Cover Page -->
  <div class="page cover">
    <img src="{{ broker_logo }}" style="height: 60px;">
    <h1>{{ property_name }}</h1>
    <h2>{{ location }}</h2>
    <img src="{{ images[0] }}" class="hero-image">
  </div>

  <!-- Executive Summary -->
  <div class="page">
    <h2>Executive Summary</h2>
    <p>{{ summary }}</p>
  </div>

  <!-- Rent Roll -->
  <div class="page">
    <h2>Tenant Rent Roll</h2>
    {{ rent_roll_table | safe }}
    <img src="{{ chart_path }}" style="width: 50%; margin: auto;">
  </div>

  <!-- Gallery -->
  <div class="page">
    <h2>Property Gallery</h2>
    {% for image in images %}
    <img src="{{ image }}" class="hero-image">
    {% endfor %}
  </div>

</body>
</html>
6
Step 6

Normalize and Inject Property Images

Use Pillow to standardize all property images to a consistent resolution before insertion. Then render the template with Jinja2 to produce the final HTML document.

python
from PIL import Image as PILImage
from pathlib import Path
from jinja2 import Environment, FileSystemLoader

def normalize_images(paths: list[str], size=(1400, 900)) -> list[str]:
    """Standardize all images to the same resolution."""
    out = []
    for path in paths:
        img = PILImage.open(path).convert("RGB")
        img.thumbnail(size, PILImage.LANCZOS)
        out_path = f"normalized_{Path(path).name}"
        img.save(out_path, quality=92)
        out.append(out_path)
    return out

def render_om_html(data, summary, rent_roll_df, metrics, chart_path) -> str:
    env = Environment(loader=FileSystemLoader("."))
    template = env.get_template("om_template.html")

    return template.render(
        property_name=data["property_name"],
        location=data["location"],
        broker_logo=data["broker_logo"],
        images=normalize_images(data["images"]),
        summary=summary,
        rent_roll_table=rent_roll_df.to_html(classes="rent-roll-table", index=False),
        chart_path=chart_path,
        metrics=metrics,
    )

html_content = render_om_html(property_data, summary, rent_roll_df, metrics, chart_path)

Minimum resolution: Enforce at least 1400×900px per image to maintain print quality in the final PDF at 150 DPI.

7
Step 7

Export the Final PDF

WeasyPrint renders your HTML (with embedded fonts, images, and CSS) into a pixel-perfect multi-page PDF. This is the final production-ready output sent to investors.

python
from weasyprint import HTML, CSS

def generate_pdf(html_content: str, output_filename: str) -> str:
    """Render HTML to a production-ready PDF."""
    HTML(string=html_content).write_pdf(
        output_filename,
        stylesheets=[CSS(string="@page { margin: 0; }")]
    )
    print(f"[✓] PDF generated: {output_filename}")
    return output_filename

output_path = generate_pdf(
    html_content,
    f"{property_data['property_name'].replace(' ', '_')}_OM.pdf"
)
Output

[✓] PDF generated: Midtown_Office_Tower_OM.pdf

— 14-page investor-ready Offering Memorandum with branding, charts, and AI narrative

Common Challenges

Pitfalls & How to Solve Them

Inconsistent Property Images

Standardize all images to a fixed resolution with Pillow's thumbnail() before insertion. Enforce a minimum of 1400×900px.

LLM Financial Hallucinations

Never ask the LLM to calculate metrics. Compute all numbers in Python with Pandas and only pass pre-calculated values to the model.

Branding Misalignment

Use parameterized CSS variables in your Jinja2 template so brand colors and fonts are injected per brokerage at render time.

Missing Property Data

Use Jinja2 conditionals ({% if data.market_overview %}) so OMs render gracefully even with partial data.

Deployment Costs

Monthly Infrastructure Cost

Generating hundreds of OMs per month remains a fraction of analyst assembly time.

ComponentMonthly Cost
LLM API (GPT-4)$30 – $200
Cloud Compute (AWS/GCP)~$50
Storage (S3/GCS)~$10
Total$90 – $260

Compared to: $200–$600 per OM in analyst time at 6–20 hours per document.

FAQs

Frequently Asked Questions

Want Production OM
Automation?

AxcelerateAI builds enterprise-grade OM generation pipelines connected to your CRM, deal database, and brokerage branding systems.

Written by AxcelerateAI Research Team • March 2026
PythonLLMPropTechDocument AIOffering Memorandum