Skip to content

Commit c8bd1bc

Browse files
authored
Merge pull request #393 from howardbaik/feat-prompt-attachments
Support multi-modal inputs (images, PDFs) in `Validate.prompt()`
2 parents 7fcdefd + 9bc4942 commit c8bd1bc

5 files changed

Lines changed: 413 additions & 7 deletions

File tree

pointblank/_interrogation.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2821,6 +2821,7 @@ def interrogate_prompt(
28212821
llm_model = ai_config["llm_model"]
28222822
batch_size = ai_config.get("batch_size", 1000)
28232823
max_concurrent = ai_config.get("max_concurrent", 3)
2824+
attachments = ai_config.get("attachments", [])
28242825

28252826
# Set up LLM configuration (api_key will be loaded from environment)
28262827
llm_config = _LLMConfig(
@@ -2852,7 +2853,7 @@ def interrogate_prompt(
28522853
prompt_builder = _PromptBuilder(prompt)
28532854

28542855
# Create AI validation engine
2855-
engine = _AIValidationEngine(llm_config)
2856+
engine = _AIValidationEngine(llm_config, attachments=attachments)
28562857

28572858
# Run AI validation synchronously (chatlas is synchronous)
28582859
batch_results = engine.validate_batches(

pointblank/_utils_ai.py

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@
33
import hashlib
44
import json
55
import logging
6+
import pathlib
67
from dataclasses import dataclass
78
from typing import Any, Dict, List, Optional, Tuple
89

910
import narwhals as nw
1011

1112
from pointblank._constants import MODEL_PROVIDERS
1213

14+
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
15+
_PDF_EXTS = frozenset({".pdf"})
16+
_SUPPORTED_ATTACHMENT_EXTS = _IMAGE_EXTS | _PDF_EXTS
17+
1318
logger = logging.getLogger(__name__)
1419

1520

@@ -93,7 +98,9 @@ def _create_chat_instance(
9398
{"index": 0, "result": true},
9499
{"index": 1, "result": false},
95100
{"index": 2, "result": true}
96-
]"""
101+
]
102+
103+
If reference attachments (images or PDFs) are provided alongside the data, use them as context when evaluating each row."""
97104

98105
# Create httpx client with SSL verification settings
99106
try:
@@ -213,6 +220,71 @@ def _create_chat_instance(
213220
return chat
214221

215222

223+
def _prepare_attachments(attachments: Optional[List[Any]]) -> List[Any]:
224+
"""
225+
Coerce a heterogeneous list of attachment specs into chatlas Content objects.
226+
227+
Accepts:
228+
- ``str`` or ``pathlib.Path``: auto-converted to ``content_image_*`` or ``content_pdf_*``
229+
based on extension. URLs (``http://``/``https://``) use the URL variants.
230+
- Pre-built chatlas ``Content`` objects: passed through untouched.
231+
232+
Raises ``ValueError`` for unsupported extensions and ``ImportError`` if chatlas
233+
is not installed.
234+
"""
235+
if not attachments:
236+
return []
237+
238+
if not isinstance(attachments, (list, tuple)):
239+
raise TypeError(f"attachments must be a list or tuple, got {type(attachments).__name__}")
240+
241+
try:
242+
from chatlas import (
243+
content_image_file,
244+
content_image_url,
245+
content_pdf_file,
246+
content_pdf_url,
247+
)
248+
except ImportError: # pragma: no cover
249+
raise ImportError(
250+
"The `chatlas` package is required for attachments support. "
251+
"Please install it using `pip install chatlas`."
252+
)
253+
254+
prepared: List[Any] = []
255+
for item in attachments:
256+
# If it's a string or path-like, coerce based on extension. Otherwise
257+
# assume it's an already-built chatlas Content object and pass through.
258+
if isinstance(item, (str, pathlib.PurePath)):
259+
path_str = str(item)
260+
# Strip query string before checking extension for URLs.
261+
ext = pathlib.PurePosixPath(path_str.split("?", 1)[0]).suffix.lower()
262+
if ext not in _SUPPORTED_ATTACHMENT_EXTS:
263+
raise ValueError(
264+
f"Unsupported attachment extension {ext!r} for {path_str!r}. "
265+
f"Supported extensions: {sorted(_SUPPORTED_ATTACHMENT_EXTS)}"
266+
)
267+
is_url = path_str.startswith(("http://", "https://"))
268+
if ext in _IMAGE_EXTS:
269+
# content_image_file emits MissingResizeWarning when no resize
270+
# is given; pass "low" explicitly (its implicit default) to
271+
# suppress it. content_image_url has no resize parameter.
272+
# Users wanting higher fidelity should pre-build the Content
273+
# with their preferred resize and pass it in directly (the
274+
# pass-through branch below handles that).
275+
prepared.append(
276+
content_image_url(path_str)
277+
if is_url
278+
else content_image_file(path_str, resize="low")
279+
)
280+
else:
281+
prepared.append(content_pdf_url(path_str) if is_url else content_pdf_file(path_str))
282+
else:
283+
prepared.append(item)
284+
285+
return prepared
286+
287+
216288
# ============================================================================
217289
# Data Batching and Optimization
218290
# ============================================================================
@@ -767,16 +839,24 @@ def combine_batch_results(
767839
class _AIValidationEngine:
768840
"""Main engine for AI-powered validation using chatlas."""
769841

770-
def __init__(self, llm_config: _LLMConfig):
842+
def __init__(
843+
self,
844+
llm_config: _LLMConfig,
845+
attachments: Optional[List[Any]] = None,
846+
):
771847
"""
772848
Initialize the AI validation engine.
773849
774850
Parameters
775851
----------
776852
llm_config
777853
Configuration for the LLM provider.
854+
attachments
855+
Optional list of chatlas ``Content`` objects sent as global context
856+
alongside every batch's text prompt.
778857
"""
779858
self.llm_config = llm_config
859+
self.attachments: List[Any] = list(attachments) if attachments else []
780860
self.chat = _create_chat_instance(
781861
provider=llm_config.provider,
782862
model_name=llm_config.model,
@@ -823,8 +903,9 @@ def validate_batch(batch: Dict[str, Any]) -> List[Dict[str, Any]]:
823903
logger.debug(prompt)
824904
logger.debug("--- PROMPT END ---")
825905

826-
# Get response from LLM using chatlas (synchronous)
827-
response = str(self.chat.chat(prompt, stream=False, echo="none"))
906+
# Get response from LLM using chatlas (synchronous). Attachments
907+
# ride along as global context content blocks for every batch.
908+
response = str(self.chat.chat(prompt, *self.attachments, stream=False, echo="none"))
828909

829910
# Debug: Log the raw LLM response
830911
logger.debug(f"📥 LLM Response for batch {batch['batch_id']}:")
@@ -890,8 +971,9 @@ def validate_single_batch(
890971
# Build the prompt for this batch
891972
prompt = prompt_builder.build_prompt(batch["data"])
892973

893-
# Get response from LLM using chatlas (synchronous)
894-
response = str(self.chat.chat(prompt, stream=False, echo="none"))
974+
# Get response from LLM using chatlas (synchronous). Attachments
975+
# ride along as global context content blocks for every batch.
976+
response = str(self.chat.chat(prompt, *self.attachments, stream=False, echo="none"))
895977

896978
# Parse the response
897979
parser = _ValidationResponseParser(total_rows=1000) # This will be set properly

pointblank/validate.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10881,6 +10881,7 @@ def prompt(
1088110881
prompt: str,
1088210882
model: str,
1088310883
columns_subset: str | list[str] | None = None,
10884+
attachments: list | None = None,
1088410885
batch_size: int = 1000,
1088510886
max_concurrent: int = 3,
1088610887
pre: Callable | None = None,
@@ -10926,6 +10927,15 @@ def prompt(
1092610927
A single column or list of columns to include in the validation. If `None`, all columns
1092710928
will be included. Specifying fewer columns can improve performance and reduce API costs
1092810929
so try to include only the columns necessary for the validation.
10930+
attachments
10931+
An optional list of reference files (images or PDFs) to attach as global context for
10932+
every batch. Each item can be a local file path, a URL (`http://` / `https://`), a
10933+
`pathlib.Path`, or a pre-built chatlas `Content` object. Supported extensions are
10934+
`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, and `.pdf`. The attachments apply to the whole
10935+
validation step, not per-row, so they are well-suited for things like brand guides,
10936+
schema diagrams, or sample documents the LLM should consult when scoring each row.
10937+
**Cost note**: attachments are re-sent on every batch — see the *Multi-modal
10938+
attachments* section below for cost-management tips.
1092910939
model
1093010940
The model to be used. This should be in the form of `provider:model` (e.g.,
1093110941
`"anthropic:claude-opus-4-6"`). Supported providers are `"anthropic"`, `"openai"`,
@@ -11094,6 +11104,39 @@ def prompt(
1109411104
- "Describe the quality of each row" (asks for description, not validation)
1109511105
- "How would you improve this data?" (asks for suggestions, not pass/fail)
1109611106

11107+
Multi-modal Attachments
11108+
-----------------------
11109+
Use `attachments=` to give the LLM a reference image or PDF that applies to every row. The
11110+
attachment is sent as global context, while each row's values are still serialized as JSON
11111+
and validated against your `prompt=`. Useful patterns:
11112+
11113+
- validating descriptions against a brand-style image: `attachments=["brand_guide.pdf"]`
11114+
- cross-referencing rows with a schema diagram: `attachments=["schema.png"]`
11115+
- matching free-text fields to a sample document: `attachments=["sample_invoice.pdf"]`
11116+
11117+
Accepted values per list item:
11118+
11119+
- local path strings or `pathlib.Path` objects (e.g., `"docs/diagram.png"`)
11120+
- URLs (e.g., `"https://example.com/diagram.png"`)
11121+
- pre-built chatlas `Content` objects (e.g., `chatlas.content_image_plot()`)
11122+
11123+
Supported extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.pdf`.
11124+
11125+
Note: local image paths auto-coerced through `attachments=` use `resize="low"` (chatlas's
11126+
default downscale to 512x512) to keep token costs predictable. For higher fidelity,
11127+
pre-build the content yourself with `chatlas.content_image_file(path, resize="high")`
11128+
(or `"none"`) and pass that object inside `attachments=`.
11129+
11130+
**Cost / batching note**: attachments are re-sent on *every* batch (one batch = one LLM API
11131+
call). For a table that requires N batches, each attachment's input tokens are billed N
11132+
times. To control costs:
11133+
11134+
- keep attachments small (downscale images, crop PDFs to the relevant page)
11135+
- use `columns_subset=` aggressively to maximize row-signature memoization (fewer unique
11136+
rows means fewer batches)
11137+
- raise `batch_size=` when the combined system prompt, attachment, and row JSON fit
11138+
comfortably under the model's context window
11139+
1109711140
Performance Considerations
1109811141
--------------------------
1109911142
AI validation is significantly slower than traditional validation methods due to API calls
@@ -11215,6 +11258,28 @@ def prompt(
1121511258
which exceeds all threshold levels. The validation will trigger the specified error action
1121611259
since the failure rate (40%) is above the error threshold (20%). The AI can recognize
1121711260
various phone number formats and determine whether they include area codes.
11261+
11262+
**Multi-modal example with `attachments=`:**
11263+
11264+
Suppose you have a table of product descriptions and a brand-style PDF that describes the
11265+
approved tone and vocabulary. Pass the PDF as a global attachment so the LLM can compare
11266+
each description against it.
11267+
11268+
```python
11269+
validation = (
11270+
pb.Validate(data=products)
11271+
.prompt(
11272+
prompt="Each product description must match the tone and vocabulary in the brand guide.",
11273+
columns_subset=["description"],
11274+
attachments=["docs/brand_guide.pdf"],
11275+
model="anthropic:claude-opus-4-6",
11276+
)
11277+
.interrogate()
11278+
)
11279+
```
11280+
11281+
The brand guide is sent alongside the row JSON on every batch, so the LLM evaluates each
11282+
description with the same reference document in view.
1121811283
"""
1121911284

1122011285
assertion_type = _get_fn_name()
@@ -11241,6 +11306,13 @@ def prompt(
1124111306
if not isinstance(max_concurrent, int) or max_concurrent < 1:
1124211307
raise ValueError("max_concurrent must be a positive integer")
1124311308

11309+
# Coerce `attachments=` into a list of chatlas Content objects. Fails fast
11310+
# on unsupported extensions so users see the error at step definition time,
11311+
# not deep inside interrogation.
11312+
from pointblank._utils_ai import _prepare_attachments
11313+
11314+
prepared_attachments = _prepare_attachments(attachments)
11315+
1124411316
_check_pre(pre=pre)
1124511317
_check_thresholds(thresholds=thresholds)
1124611318
_check_active_input(param=active, param_name="active")
@@ -11264,6 +11336,7 @@ def prompt(
1126411336
"llm_model": model_name,
1126511337
"batch_size": batch_size,
1126611338
"max_concurrent": max_concurrent,
11339+
"attachments": prepared_attachments,
1126711340
}
1126811341

1126911342
val_info = _ValidationInfo(

pointblank/validate.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ class Validate:
419419
prompt: str,
420420
model: str,
421421
columns_subset: str | list[str] | None = None,
422+
attachments: list | None = None,
422423
batch_size: int = 1000,
423424
max_concurrent: int = 3,
424425
pre: Callable | None = None,

0 commit comments

Comments
 (0)