Skip to content

Guide 07 — Attachment Text Extraction

symfonic.agent.attachments ships three public helpers for converting Attachment objects (or raw provider content-block dicts) into plain text strings.

When to extract vs. when to send natively

Scenario Recommended approach
Targeting a text-only model (legacy Ollama, custom backend) Extract — native blocks will be ignored or cause an error
Anthropic / OpenAI vision model, visual content matters (charts, diagrams, screenshots) Send natively via attachments=[...]
Anthropic / OpenAI vision model, only the text content matters (contracts, invoices) Extract — reduces per-turn token cost, text survives in HMS memory
Multi-turn conversations with attachments Extract — image tokens repeat every turn; extracted text can be cached

Installation

Install only the extras you need:

# PDF support (pdfplumber primary, pypdf fallback)
pip install "symfonic-core[attachments-pdf]"

# DOCX support
pip install "symfonic-core[attachments-docx]"

# Excel / XLSX support
pip install "symfonic-core[attachments-xlsx]"

# Image OCR (requires tesseract binary — see below)
pip install "symfonic-core[attachments-ocr]"

# All four at once
pip install "symfonic-core[attachments]"

For OCR, you also need the tesseract binary on PATH:

# macOS
brew install tesseract

# Ubuntu / Debian
apt-get install tesseract-ocr

# Windows
# Download installer from https://tesseract-ocr.github.io/tessdoc/Installation.html

API reference

extract_text(att, *, max_pages=None, page_markers=True) -> str

Extract plain text from a single attachment.

  • attAttachment instance or a raw Anthropic / OpenAI content-block dict.
  • max_pages — limit pages read from PDFs/DOCX. None = all pages. Must be a positive integer; 0 and negative values raise ValueError (Python slicing would otherwise silently drop the last page with max_pages=-1).
  • page_markers — insert --- page N --- separators between pages (default True). No marker is emitted before page 1.

Raises UnsupportedAttachmentError, ExtractionDependencyMissingError, or ExtractionFailedError (see error taxonomy below).

extract_texts(atts, *, max_pages=None, page_markers=True, on_error="warn") -> list[str]

Batch extraction over a sequence of attachments.

  • on_error controls per-item failure handling:
  • "raise" — stop on first failure (re-raises the original exception).
  • "skip" — silently omit failing items. No log output.
  • "warn" (default) — emit a logging.WARNING via the symfonic.agent.attachments.extractors logger and omit the failing item. Intercept in tests with caplog.at_level(logging.WARNING) or a custom logging.Handler; not with warnings.catch_warnings.

The returned list length may be less than len(atts) when on_error is "skip" or "warn" and any item fails. Positional correspondence between the input and output is NOT preserved. If you need a per-item failure signal, use "warn" (and read caplog) or "raise".

is_extractable(att) -> bool

Returns True if an extractor is registered for this attachment's (kind, media_type) combination AND its optional Python dependency is importable (for image OCR, also requires the tesseract binary on PATH).

Cheap predicate: does not decode or validate the base64 payload. A malformed data field still returns True when the combo is extractable — the failure only surfaces when you actually call extract_text. Never raises. Safe to use for runtime routing decisions.

Error taxonomy

AttachmentExtractionError           # base
├── UnsupportedAttachmentError      # no extractor for this kind/media_type,
                                   # or source_type="url" (not fetched)
├── ExtractionDependencyMissingError # extractor exists but pip extra missing
                                   # message names the extra to install
└── ExtractionFailedError           # dep present, file corrupt / protected
                                    # __cause__ = underlying library exception

End-to-end examples

Cost-sensitive path: extract before the agent call

from symfonic.agent.attachments import extract_text, is_extractable
from symfonic.agent import Attachment, SymfonicAgent

pdf = Attachment(
    kind="document",
    source_type="base64",
    data=pdf_b64,
    media_type="application/pdf",
    filename="contract.pdf",
)

# Route based on what's available at runtime
if is_extractable(pdf):
    text = extract_text(pdf, max_pages=20)
    await agent.run(f"{user_question}\n\nContract:\n{text}", scope=scope)
else:
    # Fall back to native block (provider must support documents)
    await agent.run(user_question, attachments=[pdf], scope=scope)

Hybrid mixed-media path

from symfonic.agent.attachments import extract_texts, is_extractable, UnsupportedAttachmentError
from symfonic.agent import Attachment, SymfonicAgent

attachments: list[Attachment] = [pdf_att, chart_image_att, logo_image_att]

# Extract text from all extractable attachments; keep visual ones native
extracted_texts: list[str] = []
native_blocks: list[Attachment] = []

for att in attachments:
    if is_extractable(att):
        extracted_texts.append(extract_text(att))
    else:
        native_blocks.append(att)

# Fold extracted text into the query string
combined_query = user_question
if extracted_texts:
    combined_query += "\n\n" + "\n\n---\n\n".join(extracted_texts)

await agent.run(
    combined_query,
    attachments=native_blocks or None,
    scope=scope,
)

Supported formats (v1)

kind media_type Library Extra
document application/pdf pdfplumber (pypdf fallback) attachments-pdf
document application/vnd.openxmlformats-officedocument.wordprocessingml.document python-docx attachments-docx
document text/plain, text/markdown, text/csv stdlib always
document application/vnd.openxmlformats-officedocument.spreadsheetml.sheet openpyxl attachments-xlsx
image image/png, image/jpeg, image/webp, image/gif pytesseract + Pillow attachments-ocr

Non-goals (v1)

  • URL fetchingsource_type="url" always raises UnsupportedAttachmentError. Download the file yourself before calling extract_text.
  • Metadata extraction (author, EXIF, creation date) — future extract_metadata() helper.
  • PDF password unlocking — raises ExtractionFailedError with a clear message.
  • Streaming extraction — returns a str, not an iterator.
  • Auto-chunking for embedder token limits — caller responsibility.