Module 8: Multimodal Document Analyzer
4. Vision Analysis
Description
The VisionAnalyzer is the intelligent component of the Document Analyzer: it receives the images extracted by the DocumentProcessor and turns them into useful information. It classifies the document type (invoice, contract, manual), extracts structured data according to the type, and generates image descriptions to index in RAG. It uses GPT-4o as the primary provider with fallback to Claude and Gemini.
Why it matters: A scanned PDF without Vision is just a collection of pixels. The VisionAnalyzer gives it meaning: "this is a $1,740 invoice from Tech Solutions". Without this component, scanned documents would be useless to the system. And with multi-provider fallback, the system keeps working even if one provider fails.
Connection with the module: In Module 2 you learned to send images to GPT-4 Vision and get analysis. Here you integrate that capability into a class with three features: classification, structured extraction, and description for RAG. In addition, you implement the fallback pattern from Module 7: if OpenAI fails or is slow, the system automatically tries Anthropic or Google.
VisionAnalyzer Architecture
Responsibilities
VisionAnalyzer
├── classify() → Determine the document type
├── extract_structured() → Extract data by type (invoice, contract, etc.)
├── describe_for_rag() → Generate text descriptions for indexing
└── _call_with_fallback() → Run with multi-provider fallback
Decision flow
ProcessedDocument
│
├── Has images? ───────── Yes ──→ classify() with the first image
│ │
│ ▼
│ extract_structured() with the type's schema
│ │
│ ▼
│ describe_for_rag() for each image
│
└── Text only? ────────── Yes ──→ classify() with text (no Vision)
│
▼
extract_structured() with a text LLM
Pydantic Models for Extraction
Base models
from pydantic import BaseModel, Field
from typing import Optional
class ExtractionResult(BaseModel):
document_type: str
fields: dict
confidence: Optional[float] = Field(None, ge=0, le=1)
raw_response: Optional[str] = None
class InvoiceData(BaseModel):
date: Optional[str] = None
invoice_number: Optional[str] = None
vendor: Optional[str] = None
recipient: Optional[str] = None
subtotal: Optional[float] = None
tax: Optional[float] = None
total: Optional[float] = None
currency: str = "MXN"
items: list[dict] = []
class ContractData(BaseModel):
contract_type: Optional[str] = None
parties: list[str] = []
signing_date: Optional[str] = None
effective_date: Optional[str] = None
subject: Optional[str] = None
amount: Optional[float] = None
key_clauses: list[str] = []
class ReportData(BaseModel):
title: Optional[str] = None
author: Optional[str] = None
date: Optional[str] = None
sections: list[str] = []
executive_summary: Optional[str] = None
Schema registry
EXTRACTION_SCHEMAS: dict[str, type[BaseModel]] = {
"invoice": InvoiceData,
"contract": ContractData,
"manual": ReportData,
"report": ReportData,
}
SCHEMA_PROMPTS: dict[str, str] = {
"invoice": "date, invoice_number, vendor, recipient, subtotal, tax, total, currency, items (description, quantity, unit_price, line_total)",
"contract": "contract_type, parties, signing_date, effective_date, subject, amount, key_clauses",
"manual": "title, author, date, sections, executive_summary",
"report": "title, author, date, sections, executive_summary",
}
DEFAULT_SCHEMA_PROMPT = "title, main_content, key_points, date (if present)"
Implementation: VisionAnalyzer
Complete class
import json
import logging
import os
from typing import Optional
from openai import OpenAI
logger = logging.getLogger(__name__)
class VisionAnalyzer:
def __init__(self):
self.openai_client = OpenAI()
self.anthropic_client = None
self.google_model = None
self._init_fallback_providers()
def _init_fallback_providers(self):
try:
import anthropic
if os.getenv("ANTHROPIC_API_KEY"):
self.anthropic_client = anthropic.Anthropic()
logger.info("Anthropic available as fallback")
except ImportError:
logger.info("Anthropic not installed — no fallback")
try:
import google.generativeai as genai
if os.getenv("GOOGLE_API_KEY"):
genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))
self.google_model = genai.GenerativeModel("gemini-1.5-flash")
logger.info("Google Gemini available as fallback")
except ImportError:
logger.info("Google GenAI not installed — no fallback")
def classify(self, content) -> str:
if content.has_image_pages:
images = content.get_images_for_vision()
return self._classify_from_image(images[0]["base64"])
if content.full_text:
return self._classify_from_text(content.full_text[:2000])
return "other"
def _classify_from_image(self, image_base64: str) -> str:
prompt = (
"Classify this document into one of these categories: "
"invoice, contract, manual, report, other. "
"Respond ONLY with the category name, no explanation."
)
def openai_call():
r = self.openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {
"url": f"data:image/png;base64,{image_base64}"
}}
]
}],
max_tokens=20,
temperature=0
)
return r.choices[0].message.content.strip().lower()
def anthropic_call():
if not self.anthropic_client:
raise RuntimeError("Anthropic not available")
r = self.anthropic_client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=20,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {
"type": "base64", "media_type": "image/png",
"data": image_base64
}},
{"type": "text", "text": prompt}
]
}]
)
return r.content[0].text.strip().lower()
return self._call_with_fallback([openai_call, anthropic_call], "classification")
def _classify_from_text(self, text: str) -> str:
r = self.openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": (
"Classify this document: invoice, contract, manual, report, other. "
f"Just the name.\n\n{text}"
)
}],
max_tokens=20,
temperature=0
)
return r.choices[0].message.content.strip().lower()
def extract_structured(self, content, doc_type: str) -> ExtractionResult:
schema_prompt = SCHEMA_PROMPTS.get(doc_type, DEFAULT_SCHEMA_PROMPT)
if content.has_image_pages:
images = content.get_images_for_vision()
data = self._extract_from_images(images[:5], schema_prompt)
elif content.full_text:
data = self._extract_from_text(content.full_text[:4000], schema_prompt)
else:
return ExtractionResult(
document_type=doc_type, fields={},
confidence=0, raw_response="No content to extract"
)
return ExtractionResult(
document_type=doc_type,
fields=data,
confidence=self._estimate_confidence(data, doc_type)
)
def _extract_from_images(self, images: list[dict], schema_prompt: str) -> dict:
prompt = (
f"Extract the following fields from this document: {schema_prompt}\n\n"
"Respond ONLY with valid JSON. "
"Use null for fields not found. "
"For empty lists use []."
)
content_parts = [{"type": "text", "text": prompt}]
for img in images:
content_parts.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img['base64']}"}
})
def openai_call():
r = self.openai_client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": content_parts}],
response_format={"type": "json_object"},
temperature=0,
max_tokens=2000
)
return json.loads(r.choices[0].message.content)
def anthropic_call():
if not self.anthropic_client:
raise RuntimeError("Anthropic not available")
ant_content = []
for img in images:
ant_content.append({
"type": "image", "source": {
"type": "base64", "media_type": "image/png",
"data": img["base64"]
}
})
ant_content.append({"type": "text", "text": prompt + "\nRespond only JSON."})
r = self.anthropic_client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=2000,
messages=[{"role": "user", "content": ant_content}]
)
return json.loads(r.content[0].text)
return self._call_with_fallback([openai_call, anthropic_call], "structured extraction")
def _extract_from_text(self, text: str, schema_prompt: str) -> dict:
r = self.openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": (
f"Extract the following fields: {schema_prompt}\n\n"
"Respond ONLY with valid JSON. Use null for not found.\n\n"
f"{text}"
)
}],
response_format={"type": "json_object"},
temperature=0,
max_tokens=2000
)
return json.loads(r.choices[0].message.content)
def describe_for_rag(self, images: list[dict]) -> list[str]:
descriptions = []
for img in images[:10]:
try:
desc = self._describe_single_image(img["base64"])
descriptions.append(f"[Page {img.get('page', '?')}] {desc}")
except Exception as e:
logger.warning(f"Error describing image on page {img.get('page', '?')}: {e}")
descriptions.append(f"[Page {img.get('page', '?')}] Image not described due to an error.")
return descriptions
def _describe_single_image(self, image_base64: str) -> str:
r = self.openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": (
"Describe the content of this document image in 2-3 sentences. "
"Include: document type, visible data, structure."
)},
{"type": "image_url", "image_url": {
"url": f"data:image/png;base64,{image_base64}"
}}
]
}],
max_tokens=200,
temperature=0
)
return r.choices[0].message.content
def _estimate_confidence(self, data: dict, doc_type: str) -> float:
if not data:
return 0.0
expected_fields = {
"invoice": ["date", "total", "vendor"],
"contract": ["parties", "signing_date", "subject"],
"manual": ["title", "sections"],
"report": ["title", "sections"],
}
required = expected_fields.get(doc_type, [])
if not required:
return 0.5
found = sum(1 for f in required if data.get(f) is not None)
return round(found / len(required), 2)
def _call_with_fallback(self, providers: list, operation: str):
last_error = None
for i, provider_fn in enumerate(providers):
try:
result = provider_fn()
if i > 0:
logger.info(f"{operation}: success with fallback provider #{i}")
return result
except Exception as e:
last_error = e
logger.warning(f"{operation}: provider #{i} failed: {e}")
continue
raise RuntimeError(
f"{operation}: all providers failed. Last error: {last_error}"
)
Multi-Page Extraction
The problem
A 10-page document can have information distributed: the date is on page 1, the items on pages 2-8, and the total on page 9. Sending only the first image loses information.
Strategy
For documents with multiple images, we send up to 5 images in a single Vision call. If there are more than 5, we prioritize:
- First page (header, general data)
- Last page (totals, signatures)
- Selected intermediate pages
def _select_pages_for_extraction(self, images: list[dict], max_pages: int = 5) -> list[dict]:
if len(images) <= max_pages:
return images
selected = [images[0], images[-1]]
remaining = images[1:-1]
step = max(1, len(remaining) // (max_pages - 2))
for i in range(0, len(remaining), step):
if len(selected) >= max_pages:
break
selected.append(remaining[i])
selected.sort(key=lambda x: x.get("page", 0))
return selected
Multi-page cost
| Pages sent | Image tokens (approx) | Cost with gpt-4o |
|---|---|---|
| 1 image | ~170-800 | $0.01-0.02 |
| 3 images | ~500-2400 | $0.02-0.06 |
| 5 images | ~850-4000 | $0.03-0.10 |
Multi-Provider Fallback in Detail
Why you need fallback
| Scenario | Without fallback | With fallback |
|---|---|---|
| OpenAI rate limit (429) | Request fails, user sees error | Claude is used, response arrives |
| OpenAI timeout (>30s) | Request timeout | Gemini is used, faster |
| OpenAI maintenance | Service down | Claude/Gemini work |
| Claude unavailable | — | Primary OpenAI is used |
Order of preference
1. OpenAI GPT-4o → Best general quality, most expensive
2. Anthropic Claude 3.5 → Comparable quality, good fallback
3. Google Gemini Flash → Cheapest, good for classification
Fallback configuration
PROVIDER_CONFIG = {
"openai": {
"timeout": 30,
"max_retries": 1,
"models": {
"vision": "gpt-4o",
"classify": "gpt-4o-mini",
"describe": "gpt-4o-mini"
}
},
"anthropic": {
"timeout": 30,
"max_retries": 1,
"models": {
"vision": "claude-3-5-sonnet-20241022",
"classify": "claude-3-5-sonnet-20241022"
}
},
"google": {
"timeout": 20,
"max_retries": 1,
"models": {
"vision": "gemini-1.5-flash",
"classify": "gemini-1.5-flash"
}
}
}
Troubleshooting
"Extraction returns empty or null fields"
Probable cause: The image is low resolution or the document has small text.
Solution: Increase the rendering resolution:
processor = DocumentProcessor(dpi_scale=300/72) # 300 DPI instead of 150
Or use a more capable model:
# Switch from gpt-4o-mini to gpt-4o for extraction
r = self.openai_client.chat.completions.create(
model="gpt-4o", # more capable for complex documents
...
)
"The classifier returns 'other' for common documents"
Probable cause: The classification prompt needs more context.
Solution: Improve the prompt with examples:
CLASSIFICATION_PROMPT = """Classify this document into a category:
- invoice: billing documents with amounts, items, tax
- contract: legal agreements between parties with clauses
- manual: technical documentation with instructions
- report: reports with data, charts, conclusions
- other: any document that doesn't fit the previous ones
Respond ONLY with the category name."""
"Fallback to Anthropic fails with an image format error"
Probable cause: The image is JPEG but is sent as image/png.
Solution: Detect the real format:
import imghdr
def detect_media_type(image_base64: str) -> str:
raw = base64.b64decode(image_base64[:100])
img_type = imghdr.what(None, h=raw)
media_types = {
"jpeg": "image/jpeg",
"png": "image/png",
"webp": "image/webp",
"gif": "image/gif"
}
return media_types.get(img_type, "image/png")
"Rate limit (429) when extracting from multiple documents"
Solution: Add client-side rate limiting:
import time
def extract_batch(self, documents: list, delay: float = 1.0) -> list[ExtractionResult]:
results = []
for doc in documents:
result = self.extract_structured(doc, doc.get("type", "other"))
results.append(result)
time.sleep(delay)
return results
Using the VisionAnalyzer
Complete example
processor = DocumentProcessor()
analyzer = VisionAnalyzer()
doc = processor.process("invoice_march.pdf")
doc_type = analyzer.classify(doc)
print(f"Document type: {doc_type}")
extraction = analyzer.extract_structured(doc, doc_type)
print(f"Confidence: {extraction.confidence}")
print(f"Extracted data:")
for key, value in extraction.fields.items():
print(f" {key}: {value}")
if doc.has_image_pages:
descriptions = analyzer.describe_for_rag(doc.get_images_for_vision())
print(f"\nDescriptions for RAG:")
for desc in descriptions:
print(f" {desc}")
Expected output
Document type: invoice
Confidence: 1.0
Extracted data:
date: 2025-03-15
invoice_number: FAC-2025-0042
vendor: Tech Solutions S.A.
recipient: Company ABC
subtotal: 1500.0
tax: 240.0
total: 1740.0
currency: MXN
items: [{'description': 'Software license', 'quantity': 1, ...}]
Descriptions for RAG:
[Page 1] Commercial invoice from Tech Solutions S.A. with number FAC-2025-0042...
Exercises
Exercise 1: Dynamic schema by classification
Implement a complete flow that: (1) classifies the document, (2) selects the correct schema, (3) extracts data, (4) validates with the corresponding Pydantic model. If the classification is unknown, use a generic schema.
See solution
def analyze_document(content, analyzer: VisionAnalyzer) -> ExtractionResult:
doc_type = analyzer.classify(content)
logger.info(f"Document classified as: {doc_type}")
extraction = analyzer.extract_structured(content, doc_type)
model_class = EXTRACTION_SCHEMAS.get(doc_type)
if model_class:
try:
validated = model_class(**extraction.fields)
extraction.fields = validated.model_dump()
logger.info(f"Data validated with {model_class.__name__}")
except Exception as e:
logger.warning(f"Validation failed: {e}. Using raw data.")
else:
logger.info(f"No specific schema for '{doc_type}', data without extra validation")
return extraction
processor = DocumentProcessor()
analyzer = VisionAnalyzer()
doc = processor.process("document.pdf")
result = analyze_document(doc, analyzer)
print(f"Type: {result.document_type}")
print(f"Fields: {json.dumps(result.fields, indent=2, ensure_ascii=False)}")
print(f"Confidence: {result.confidence}")
Exercise 2: Complete fallback with Google Gemini
Extend _extract_from_images to include Google Gemini as a third fallback provider. Gemini uses a different API: it accepts images as PIL.Image or as bytes with upload_file. Implement the Gemini function and add it to the fallback chain.
See solution
import base64
from PIL import Image
import io
def gemini_extract(self, images: list[dict], schema_prompt: str) -> dict:
if not self.google_model:
raise RuntimeError("Google Gemini not available")
pil_images = []
for img in images[:5]:
raw = base64.b64decode(img["base64"])
pil_images.append(Image.open(io.BytesIO(raw)))
prompt = (
f"Extract the following fields from this document: {schema_prompt}\n"
"Respond ONLY with valid JSON. Use null for not found."
)
content_parts = pil_images + [prompt]
response = self.google_model.generate_content(
content_parts,
generation_config={"temperature": 0, "max_output_tokens": 2000}
)
text = response.text
if text.startswith("```"):
text = text.split("\n", 1)[1].rsplit("```", 1)[0]
return json.loads(text)
def _extract_from_images_with_gemini(self, images, schema_prompt):
def openai_call():
return self._extract_from_images_openai(images, schema_prompt)
def anthropic_call():
return self._extract_from_images_anthropic(images, schema_prompt)
def google_call():
return gemini_extract(self, images, schema_prompt)
return self._call_with_fallback(
[openai_call, anthropic_call, google_call],
"structured extraction"
)
Summary
- The VisionAnalyzer turns document images into structured data.
- Three main functions: classify the type, extract data by schema, describe for RAG.
- Multi-provider fallback: OpenAI → Anthropic → Google, automatic and transparent.
- The extraction schemas vary by document type (invoice, contract, manual).
- Multi-page extraction selects the most informative pages to optimize costs.
- Confidence is estimated by comparing found fields vs expected fields.
- Troubleshooting: image resolution, classification prompts, image format, rate limits.
Additional Resources
- OpenAI Vision Guide — GPT-4 Vision
- Anthropic Vision Docs — Claude Vision
- Google Gemini Vision — Gemini multimodal
- Module 2 of this guide — Vision foundation