Skip to content

Commit 5f88704

Browse files
authored
feat: Support documents (#66)
1 parent 565dc9f commit 5f88704

9 files changed

Lines changed: 211 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## Project Overview
66

7-
Banks is a Python prompt programming language and templating system for LLM applications. It provides a Jinja2-based template engine with specialized extensions and filters for creating dynamic prompts, managing chat messages, handling multimodal content (images/audio), and integrating with various LLM providers through LiteLLM.
7+
Banks is a Python prompt programming language and templating system for LLM applications. It provides a Jinja2-based template engine with specialized extensions and filters for creating dynamic prompts, managing chat messages, handling multimodal content (images/audio/documents), and integrating with various LLM providers through LiteLLM.
88

99
## Development Commands
1010

@@ -42,7 +42,7 @@ Banks is a Python prompt programming language and templating system for LLM appl
4242

4343
**Type System** (`src/banks/types.py`):
4444
- `ChatMessage`: Core chat message structure with role and content
45-
- `ContentBlock`: Handles different content types (text, image_url, audio) with optional cache control
45+
- `ContentBlock`: Handles different content types (text, image_url, audio, document) with optional cache control
4646
- `Tool`: Function calling support with automatic schema generation from Python callables
4747
- `CacheControl`: Anthropic-style prompt caching metadata
4848

@@ -67,6 +67,7 @@ Banks is a Python prompt programming language and templating system for LLM appl
6767
**Core Filters** (`src/banks/filters/`):
6868
- `image`: Convert file paths/URLs to base64-encoded image content blocks
6969
- `audio`: Convert audio files to base64-encoded audio content blocks
70+
- `document`: Convert documents (PDF, TXT, HTML, CSS, XML, CSV, RTF, JS, JSON) to base64-encoded content blocks
7071
- `cache_control`: Add Anthropic cache control metadata to content blocks
7172
- `tool`: Convert Python callables to LLM function call schemas
7273
- `lemmatize`: Text lemmatization using simplemma
@@ -95,7 +96,7 @@ Banks is a Python prompt programming language and templating system for LLM appl
9596
4. Caching layer prevents re-rendering identical contexts
9697

9798
### Multimodal Content Handling
98-
- Images/audio converted to base64 during filter application
99+
- Images/audio/documents converted to base64 during filter application
99100
- Content blocks maintain type safety and metadata
100101
- Cache control integrated at content block level
101102

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,11 @@ print(p.chat_messages({"persona": "helpful assistant"}))
9494
# [
9595
# ChatMessage(role='system', content=[
9696
# ContentBlock(type=<ContentBlockType.text: 'text'>, cache_control=None, text='You are a helpful assistant.',
97-
# image_url=None, input_audio=None)
97+
# image_url=None, input_audio=None, input_document=None)
9898
# ], tool_call_id=None, name=None),
9999
# ChatMessage(role='user', content=[
100100
# ContentBlock(type=<ContentBlockType.text: 'text'>, cache_control=None, text='Hello, how are you?',
101-
# image_url=None, input_audio=None)
101+
# image_url=None, input_audio=None, input_document=None)
102102
# ], tool_call_id=None, name=None)
103103
# ]
104104
```

src/banks/env.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from jinja2 import Environment, select_autoescape
55

66
from .config import config
7-
from .filters import audio, cache_control, image, lemmatize, tool, xml
7+
from .filters import audio, cache_control, document, image, lemmatize, tool, xml
88

99

1010
def _add_extensions(_env):
@@ -38,6 +38,7 @@ def _add_extensions(_env):
3838
env.filters["lemmatize"] = lemmatize
3939
env.filters["tool"] = tool
4040
env.filters["audio"] = audio
41+
env.filters["document"] = document
4142
env.filters["to_xml"] = xml
4243

4344
_add_extensions(env)

src/banks/filters/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
# SPDX-License-Identifier: MIT
44
from .audio import audio
55
from .cache_control import cache_control
6+
from .document import document
67
from .image import image
78
from .lemmatize import lemmatize
89
from .tool import tool
910
from .xml import xml
1011

11-
__all__ = ("cache_control", "image", "lemmatize", "tool", "audio", "xml")
12+
__all__ = ("cache_control", "image", "lemmatize", "tool", "audio", "document", "xml")

src/banks/filters/document.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# SPDX-FileCopyrightText: 2023-present Massimiliano Pippi <mpippi@gmail.com>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
import re
5+
from pathlib import Path
6+
from typing import cast
7+
from urllib.parse import urlparse
8+
9+
from banks.types import ContentBlock, DocumentFormat, InputDocument
10+
11+
BASE64_DOCUMENT_REGEX = re.compile(r"(text|application)\/.*;base64,.*")
12+
13+
14+
def _is_url(string: str) -> bool:
15+
"""Check if a string is a URL."""
16+
result = urlparse(string)
17+
if not result.scheme:
18+
return False
19+
20+
if not result.netloc:
21+
# The only valid format when netloc is empty is base64 data urls
22+
return all([result.scheme == "data", BASE64_DOCUMENT_REGEX.match(result.path)])
23+
24+
return True
25+
26+
27+
def _get_document_format_from_url(url: str) -> DocumentFormat:
28+
"""Extract document format from URL.
29+
30+
Tries to determine format from URL path or defaults to pdf.
31+
"""
32+
parsed = urlparse(url)
33+
path = parsed.path.lower()
34+
# Gemini supported file types https://ai.google.dev/gemini-api/docs/file-input-methods
35+
# text/html
36+
# text/css
37+
# text/plain
38+
# text/xml
39+
# text/scv
40+
# text/rtf
41+
# text/javascript
42+
# application/json
43+
# application/pdf
44+
45+
# Claude supported file types
46+
# application/pdf
47+
# text/plain
48+
49+
# OpenAI supported file types
50+
# application/pdf
51+
52+
for fmt in (
53+
"pdf",
54+
"html",
55+
"htm",
56+
"xhtml",
57+
"css",
58+
"txt",
59+
"md",
60+
"markdown",
61+
"rst",
62+
"xml",
63+
"csv",
64+
"rtf",
65+
"js",
66+
"mjs",
67+
"cjs",
68+
"javascript",
69+
"json",
70+
):
71+
if path.endswith(f".{fmt}"):
72+
return cast(DocumentFormat, fmt)
73+
# Default to pdf if format cannot be determined
74+
return "pdf"
75+
76+
77+
def document(value: str) -> str:
78+
"""Wrap the filtered value into a ContentBlock of type document.
79+
80+
The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
81+
82+
Supports both file paths and URLs (including data URLs).
83+
84+
Example:
85+
```jinja
86+
{{ "path/to/document/file.pdf" | document }}
87+
{{ "https://example.com/document.pdf" | document }}
88+
```
89+
"""
90+
if _is_url(value):
91+
document_format = _get_document_format_from_url(value)
92+
input_document = InputDocument.from_url(value, document_format)
93+
else:
94+
input_document = InputDocument.from_path(Path(value))
95+
block = ContentBlock.model_validate({"type": "document", "input_document": input_document})
96+
return f"<content_block>{block.model_dump_json()}</content_block>"

src/banks/types.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class ContentBlockType(str, Enum):
2323
text = "text"
2424
image_url = "image_url"
2525
audio = "audio"
26+
document = "document"
2627

2728

2829
class CacheControl(BaseModel):
@@ -43,6 +44,7 @@ def from_path(cls, file_path: Path) -> Self:
4344

4445

4546
AudioFormat = Literal["mp3", "wav", "m4a", "webm", "ogg", "flac"]
47+
DocumentFormat = Literal["pdf", "html", "css", "plain", "xml", "csv", "rtf", "javascript", "json"]
4648

4749

4850
class InputAudio(BaseModel):
@@ -70,12 +72,38 @@ def from_url(cls, url: str, audio_format: AudioFormat) -> Self:
7072
return cls(data=url, format=audio_format)
7173

7274

75+
class InputDocument(BaseModel):
76+
data: str
77+
format: DocumentFormat
78+
79+
@classmethod
80+
def from_path(cls, file_path: Path) -> Self:
81+
with open(file_path, "rb") as document_file:
82+
encoded_str = base64.b64encode(document_file.read()).decode("utf-8")
83+
file_format = cast(DocumentFormat, file_path.suffix[1:])
84+
return cls(data=encoded_str, format=file_format)
85+
86+
@classmethod
87+
def from_url(cls, url: str, document_format: DocumentFormat) -> Self:
88+
"""Create InputDocument from a URL.
89+
90+
Args:
91+
url: The URL to the document file
92+
document_format: The document format
93+
94+
Returns:
95+
InputDocument instance with the URL as data
96+
"""
97+
return cls(data=url, format=document_format)
98+
99+
73100
class ContentBlock(BaseModel):
74101
type: ContentBlockType
75102
cache_control: CacheControl | None = None
76103
text: str | None = None
77104
image_url: ImageUrl | None = None
78105
input_audio: InputAudio | None = None
106+
input_document: InputDocument | None = None
79107

80108

81109
ChatMessageContent = Union[list[ContentBlock], str]

tests/data/1x1.pdf

1.97 KB
Binary file not shown.

tests/test_cache_control.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ def test_cache_control():
55
res = cache_control("foo", "ephemeral")
66
res = res.replace("<content_block>", "")
77
res = res.replace("</content_block>", "")
8-
assert (
9-
res == '{"type":"text","cache_control":{"type":"ephemeral"},"text":"foo","image_url":null,"input_audio":null}'
8+
assert res == (
9+
'{"type":"text","cache_control":{"type":"ephemeral"},"text":"foo","image_url":null,"input_audio":null,'
10+
'"input_document":null}'
1011
)

tests/test_document.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import json
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from banks import Prompt
7+
from banks.filters.document import _get_document_format_from_url, _is_url, document
8+
9+
10+
@pytest.fixture
11+
def tiny_pdf():
12+
here = Path(__file__).parent
13+
return here / "data" / "1x1.pdf"
14+
15+
16+
def test_document_with_file_path(tiny_pdf):
17+
"""Test document filter with a file path input"""
18+
result = document(str(tiny_pdf))
19+
20+
# Verify the content block wrapper
21+
assert result.startswith("<content_block>")
22+
assert result.endswith("</content_block>")
23+
24+
# Parse the JSON content
25+
json_content = result[15:-16] # Remove wrapper tags
26+
content_block = json.loads(json_content)
27+
28+
assert content_block["type"] == "document"
29+
assert content_block["input_document"]["format"].startswith("pdf")
30+
31+
32+
def test_document_with_nonexistent_file():
33+
"""Test document filter with a nonexistent file path"""
34+
with pytest.raises(FileNotFoundError):
35+
document("nonexistent/document.pdf")
36+
37+
38+
def test_document_with_url():
39+
"""Test document filter with a URL input (no filesystem access)."""
40+
url = "https://example.com/document.css"
41+
result = document(url)
42+
43+
assert result.startswith("<content_block>")
44+
assert result.endswith("</content_block>")
45+
46+
json_content = result[15:-16] # Remove wrapper tags
47+
content_block = json.loads(json_content)
48+
49+
assert content_block["type"] == "document"
50+
assert content_block["input_document"]["data"] == url
51+
assert content_block["input_document"]["format"] == "css"
52+
53+
54+
def test_is_url_variants():
55+
assert _is_url("relative/path.pdf") is False
56+
assert _is_url("https://example.com/document.pdf") is True
57+
assert _is_url("data:application/pdf;base64,AAAA") is True
58+
assert _is_url("data:text/plain;base64,AAAA") is True
59+
assert _is_url("data:audio/mp3;base64,AAAA") is False
60+
61+
62+
def test_get_document_format_from_url():
63+
assert _get_document_format_from_url("https://example.com/document.WAV") == "pdf"
64+
assert _get_document_format_from_url("https://example.com/document") == "pdf"
65+
66+
67+
def test_document_no_chat_block(tiny_pdf):
68+
prompt = Prompt("{{ test }} and {{ another | document }}")
69+
messages = prompt.chat_messages({"test": "hello world", "another": str(tiny_pdf)})
70+
assert len(messages) == 1
71+
message = messages[0]
72+
assert len(message.content) == 2
73+
assert message.content[0].text == "hello world and" # type: ignore
74+
assert message.content[1].type == "document" # type:ignore

0 commit comments

Comments
 (0)