Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 34 additions & 21 deletions biochatter/llm_connect/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,10 +501,10 @@ def append_user_message(self, message: str) -> None:
def append_image_message(
self,
message: str,
image_url: str,
image_url: str | list[str],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enforce accepting only image_urls: str | list[str] (either web urls or base64 encoded0, and also raise errors if incorrect. The whole concept of "local files" shouldn't exist at this level and providing the correct url should be handled by the UI layer.

local: bool = False,
) -> None:
"""Add a user message with an image to the conversation.
"""Add a user message with one or more images to the conversation.

Also checks, in addition to the `local` flag, if the image URL is a
local file path. If it is local, the image will be encoded as a base64
Expand All @@ -513,25 +513,32 @@ def append_image_message(
Args:
----
message (str): The message from the user.
image_url (str): The URL of the image.
local (bool): Whether the image is local or not. If local, it will
be encoded as a base64 string to be passed to the LLM.
image_url (str | list[str]): The URL(s) of the image(s). Can be a single
URL string or a list of URL strings for multiple images.
local (bool): Whether the image(s) are local or not. If local, they will
be encoded as base64 strings to be passed to the LLM.

"""
parsed_url = urllib.parse.urlparse(image_url)
if local or not parsed_url.netloc:
image_url = f"data:image/jpeg;base64,{encode_image(image_url)}"
# Ensure image_url is always a list for consistent processing
if isinstance(image_url, str):
image_urls = [image_url]
else:
image_url = f"data:image/jpeg;base64,{encode_image_from_url(image_url)}"
image_urls = image_url

self.messages.append(
HumanMessage(
content=[
{"type": "text", "text": message},
{"type": "image_url", "image_url": {"url": image_url}},
],
),
)
# Build the content list starting with the text message
content = [{"type": "text", "text": message}]

# Process each image and add to content
for url in image_urls:
parsed_url = urllib.parse.urlparse(url)
if local or not parsed_url.netloc:
encoded_url = f"data:image/jpeg;base64,{encode_image(url)}"
else:
encoded_url = f"data:image/jpeg;base64,{encode_image_from_url(url)}"

content.append({"type": "image_url", "image_url": {"url": encoded_url}})

self.messages.append(HumanMessage(content=content))

def setup(self, context: str) -> None:
"""Set up the conversation with general prompts and a context."""
Expand Down Expand Up @@ -565,7 +572,8 @@ def setup_data_input_tool(self, df, input_file_name: str) -> None:
def query(
self,
text: str,
image_url: str | None = None,
image_url: str | list[str] | None = None,
local: bool = False,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the param name should refer to the image-related function, otherwise could be unclear (this is the generic query function, and params typically refer to the purpose of the method). Call it image_local or similar?

structured_model: BaseModel | None = None,
wrap_structured_output: bool | None = None,
tools: list[Callable] | None = None,
Expand All @@ -588,8 +596,13 @@ def query(
----
text (str): The user query.

image_url (str): The URL of an image to include in the conversation.
Optional and only supported for models with vision capabilities.
image_url (str | list[str] | None): The URL(s) of image(s) to include
in the conversation. Can be a single URL string, a list of URL strings
for multiple images, or None. Optional and only supported for models
with vision capabilities.

local (bool): Whether the image(s) are local files or not. If True,
images will be encoded as base64 strings. Defaults to False.

structured_model (BaseModel): The structured output model to use for the query.

Expand Down Expand Up @@ -648,7 +661,7 @@ def query(
if not image_url:
self.append_user_message(text)
else:
self.append_image_message(text, image_url)
self.append_image_message(text, image_url, local=local)

self._inject_context(text)

Expand Down
102 changes: 102 additions & 0 deletions biochatter/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import io
import logging
from pathlib import Path

import fitz # PyMuPDF
from PIL import Image

# Configure logging
logger = logging.getLogger(__name__)


def pdf_pages_to_images(
pdf_path: str | Path,
destination_folder: str | Path,
image_format: str = "PNG",
dpi: int = 300,
prefix: str = "page",
) -> list[str]:
"""Extract all pages from a PDF file and save them as separate image files.

Args:
pdf_path: Path to the input PDF file
destination_folder: Path to the folder where images will be saved
image_format: Image format to save (PNG, JPEG, etc.). Defaults to PNG
dpi: Resolution for the output images. Defaults to 150
prefix: Prefix for the output image filenames. Defaults to "page"

Returns:
List of paths to the created image files

Raises:
FileNotFoundError: If the PDF file doesn't exist
ValueError: If the PDF file is invalid or corrupted
OSError: If there's an error creating the destination folder or saving images

"""
pdf_path = Path(pdf_path)
destination_folder = Path(destination_folder)

# Validate input PDF file
if not pdf_path.exists():
raise FileNotFoundError(f"PDF file not found: {pdf_path}")

if not pdf_path.suffix.lower() == ".pdf":
raise ValueError(f"File is not a PDF: {pdf_path}")

# Create destination folder if it doesn't exist
try:
destination_folder.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.error(f"Error creating destination folder {destination_folder}: {e}")
raise

created_files = []

try:
# Open the PDF document
pdf_document = fitz.open(pdf_path)

logger.info(f"Processing PDF with {len(pdf_document)} pages")

# Extract each page as an image
for page_num in range(len(pdf_document)):
try:
# Get the page
page = pdf_document[page_num]

# Create a transformation matrix for the desired DPI
zoom = dpi / 72 # Convert DPI to zoom factor
mat = fitz.Matrix(zoom, zoom)

# Render page to an image (using correct PyMuPDF API)
pix = page.get_pixmap(matrix=mat) # type: ignore[attr-defined]

# Convert pixmap to bytes and then to PIL Image
img_data = pix.tobytes("png")
img = Image.open(io.BytesIO(img_data))

# Generate filename with zero-padded page numbers
total_pages = len(pdf_document)
padding = len(str(total_pages))
filename = f"{prefix}_{page_num + 1:0{padding}d}.{image_format.lower()}"
output_path = destination_folder / filename

# Save the image
img.save(output_path, format=image_format.upper())
created_files.append(str(output_path))

logger.debug(f"Saved page {page_num + 1} as {output_path}")

except Exception as e:
logger.error(f"Error processing page {page_num + 1}: {e}")
continue

pdf_document.close()

except Exception as e:
logger.error(f"Error opening PDF file {pdf_path}: {e}")
raise ValueError(f"Invalid or corrupted PDF file: {pdf_path}") from e

logger.info(f"Successfully extracted {len(created_files)} pages from {pdf_path}")
return created_files
33 changes: 33 additions & 0 deletions docs/features/multimodal.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,39 @@ msg, token_usage, correction = conversation.query(
)
```

### Parsing PDF as images

Recent multimodal models achieve strong performance on image processing tasks.
To leverage this, we provide the `pdf_pages_to_images` utility, which converts a
PDF into a list of page images. These images can be passed to the `query`
method to enrich the context of your queries.

Since PDF parsing can be noisy, especially when documents contain many embedded
images, this option allows you to pass a list of pre-extracted images directly
to the `query` method, enabling a fast document question-answering.

```python
from biochatter.utils import pdf_pages_to_images
from biochatter.llm_connect import LangChainConversation

convo = LangChainConversation(
model_name="gemini-2.5-flash-05-20-preview",
model_provider="google_genai",
prompts={},
)

convo.set_api_key()

pdf_path = "/path/to/your/pdf.pdf"
output_folder = "/path/to/output/folder"
images = pdf_pages_to_images(pdf_path, output_folder)

convo.query(
"Summarize the content of this document",
image_url=images,
)
```

### Open-source multimodal models

While OpenAI models work seamlessly, open-source multimodal models can be buggy
Expand Down
Loading