ai-pipeline-core 0.2.6__py3-none-any.whl → 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai_pipeline_core/__init__.py +78 -125
- ai_pipeline_core/deployment/__init__.py +34 -0
- ai_pipeline_core/deployment/base.py +861 -0
- ai_pipeline_core/deployment/contract.py +80 -0
- ai_pipeline_core/deployment/deploy.py +561 -0
- ai_pipeline_core/deployment/helpers.py +97 -0
- ai_pipeline_core/deployment/progress.py +126 -0
- ai_pipeline_core/deployment/remote.py +116 -0
- ai_pipeline_core/docs_generator/__init__.py +54 -0
- ai_pipeline_core/docs_generator/__main__.py +5 -0
- ai_pipeline_core/docs_generator/cli.py +196 -0
- ai_pipeline_core/docs_generator/extractor.py +324 -0
- ai_pipeline_core/docs_generator/guide_builder.py +644 -0
- ai_pipeline_core/docs_generator/trimmer.py +35 -0
- ai_pipeline_core/docs_generator/validator.py +114 -0
- ai_pipeline_core/document_store/__init__.py +13 -0
- ai_pipeline_core/document_store/_summary.py +9 -0
- ai_pipeline_core/document_store/_summary_worker.py +170 -0
- ai_pipeline_core/document_store/clickhouse.py +492 -0
- ai_pipeline_core/document_store/factory.py +38 -0
- ai_pipeline_core/document_store/local.py +312 -0
- ai_pipeline_core/document_store/memory.py +85 -0
- ai_pipeline_core/document_store/protocol.py +68 -0
- ai_pipeline_core/documents/__init__.py +12 -14
- ai_pipeline_core/documents/_context_vars.py +85 -0
- ai_pipeline_core/documents/_hashing.py +52 -0
- ai_pipeline_core/documents/attachment.py +85 -0
- ai_pipeline_core/documents/context.py +128 -0
- ai_pipeline_core/documents/document.py +318 -1434
- ai_pipeline_core/documents/mime_type.py +37 -82
- ai_pipeline_core/documents/utils.py +4 -12
- ai_pipeline_core/exceptions.py +10 -62
- ai_pipeline_core/images/__init__.py +309 -0
- ai_pipeline_core/images/_processing.py +151 -0
- ai_pipeline_core/llm/__init__.py +6 -4
- ai_pipeline_core/llm/ai_messages.py +130 -81
- ai_pipeline_core/llm/client.py +327 -193
- ai_pipeline_core/llm/model_options.py +14 -86
- ai_pipeline_core/llm/model_response.py +60 -103
- ai_pipeline_core/llm/model_types.py +16 -34
- ai_pipeline_core/logging/__init__.py +2 -7
- ai_pipeline_core/logging/logging.yml +1 -1
- ai_pipeline_core/logging/logging_config.py +27 -37
- ai_pipeline_core/logging/logging_mixin.py +15 -41
- ai_pipeline_core/observability/__init__.py +32 -0
- ai_pipeline_core/observability/_debug/__init__.py +30 -0
- ai_pipeline_core/observability/_debug/_auto_summary.py +94 -0
- ai_pipeline_core/observability/_debug/_config.py +95 -0
- ai_pipeline_core/observability/_debug/_content.py +764 -0
- ai_pipeline_core/observability/_debug/_processor.py +98 -0
- ai_pipeline_core/observability/_debug/_summary.py +312 -0
- ai_pipeline_core/observability/_debug/_types.py +75 -0
- ai_pipeline_core/observability/_debug/_writer.py +843 -0
- ai_pipeline_core/observability/_document_tracking.py +146 -0
- ai_pipeline_core/observability/_initialization.py +194 -0
- ai_pipeline_core/observability/_logging_bridge.py +57 -0
- ai_pipeline_core/observability/_summary.py +81 -0
- ai_pipeline_core/observability/_tracking/__init__.py +6 -0
- ai_pipeline_core/observability/_tracking/_client.py +178 -0
- ai_pipeline_core/observability/_tracking/_internal.py +28 -0
- ai_pipeline_core/observability/_tracking/_models.py +138 -0
- ai_pipeline_core/observability/_tracking/_processor.py +158 -0
- ai_pipeline_core/observability/_tracking/_service.py +311 -0
- ai_pipeline_core/observability/_tracking/_writer.py +229 -0
- ai_pipeline_core/{tracing.py → observability/tracing.py} +139 -283
- ai_pipeline_core/pipeline/__init__.py +10 -0
- ai_pipeline_core/pipeline/decorators.py +915 -0
- ai_pipeline_core/pipeline/options.py +16 -0
- ai_pipeline_core/prompt_manager.py +16 -102
- ai_pipeline_core/settings.py +26 -31
- ai_pipeline_core/testing.py +9 -0
- ai_pipeline_core-0.4.1.dist-info/METADATA +807 -0
- ai_pipeline_core-0.4.1.dist-info/RECORD +76 -0
- {ai_pipeline_core-0.2.6.dist-info → ai_pipeline_core-0.4.1.dist-info}/WHEEL +1 -1
- ai_pipeline_core/documents/document_list.py +0 -420
- ai_pipeline_core/documents/flow_document.py +0 -112
- ai_pipeline_core/documents/task_document.py +0 -117
- ai_pipeline_core/documents/temporary_document.py +0 -74
- ai_pipeline_core/flow/__init__.py +0 -9
- ai_pipeline_core/flow/config.py +0 -483
- ai_pipeline_core/flow/options.py +0 -75
- ai_pipeline_core/pipeline.py +0 -718
- ai_pipeline_core/prefect.py +0 -63
- ai_pipeline_core/simple_runner/__init__.py +0 -14
- ai_pipeline_core/simple_runner/cli.py +0 -254
- ai_pipeline_core/simple_runner/simple_runner.py +0 -247
- ai_pipeline_core/storage/__init__.py +0 -8
- ai_pipeline_core/storage/storage.py +0 -628
- ai_pipeline_core/utils/__init__.py +0 -8
- ai_pipeline_core/utils/deploy.py +0 -373
- ai_pipeline_core/utils/remote_deployment.py +0 -269
- ai_pipeline_core-0.2.6.dist-info/METADATA +0 -500
- ai_pipeline_core-0.2.6.dist-info/RECORD +0 -41
- {ai_pipeline_core-0.2.6.dist-info → ai_pipeline_core-0.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Internal image processing logic: planning, splitting, encoding."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from math import ceil
|
|
6
|
+
|
|
7
|
+
from PIL import Image, ImageOps
|
|
8
|
+
|
|
9
|
+
PIL_MAX_PIXELS = 100_000_000 # 100MP security limit
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class SplitPlan:
|
|
14
|
+
"""Describes how to split an image into parts."""
|
|
15
|
+
|
|
16
|
+
tile_width: int
|
|
17
|
+
tile_height: int
|
|
18
|
+
step_y: int
|
|
19
|
+
num_parts: int
|
|
20
|
+
trim_width: int | None # None = no trim needed
|
|
21
|
+
warnings: list[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def plan_split( # noqa: PLR0917
|
|
25
|
+
width: int,
|
|
26
|
+
height: int,
|
|
27
|
+
max_dimension: int,
|
|
28
|
+
max_pixels: int,
|
|
29
|
+
overlap_fraction: float,
|
|
30
|
+
max_parts: int,
|
|
31
|
+
) -> SplitPlan:
|
|
32
|
+
"""Calculate how to split an image. Pure function, no side effects.
|
|
33
|
+
|
|
34
|
+
Returns a SplitPlan describing tile size, step, and number of parts.
|
|
35
|
+
"""
|
|
36
|
+
warnings: list[str] = []
|
|
37
|
+
|
|
38
|
+
# Effective tile size respecting both max_dimension and max_pixels
|
|
39
|
+
tile_size = max_dimension
|
|
40
|
+
while tile_size * tile_size > max_pixels and tile_size > 100:
|
|
41
|
+
tile_size -= 10
|
|
42
|
+
|
|
43
|
+
# Width: trim if needed (left-aligned, web content is left-aligned)
|
|
44
|
+
trim_width = tile_size if width > tile_size else None
|
|
45
|
+
|
|
46
|
+
effective_width = min(width, tile_size)
|
|
47
|
+
|
|
48
|
+
# If single-tile pixel budget is still exceeded by width * tile_height, reduce tile_height
|
|
49
|
+
tile_h = tile_size
|
|
50
|
+
while effective_width * tile_h > max_pixels and tile_h > 100:
|
|
51
|
+
tile_h -= 10
|
|
52
|
+
|
|
53
|
+
# No vertical split needed
|
|
54
|
+
if height <= tile_h:
|
|
55
|
+
return SplitPlan(
|
|
56
|
+
tile_width=effective_width,
|
|
57
|
+
tile_height=height,
|
|
58
|
+
step_y=0,
|
|
59
|
+
num_parts=1,
|
|
60
|
+
trim_width=trim_width,
|
|
61
|
+
warnings=warnings,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Vertical split with overlap
|
|
65
|
+
overlap_px = int(tile_h * overlap_fraction)
|
|
66
|
+
step = tile_h - overlap_px
|
|
67
|
+
if step <= 0:
|
|
68
|
+
step = 1
|
|
69
|
+
|
|
70
|
+
num_parts = 1 + ceil((height - tile_h) / step)
|
|
71
|
+
|
|
72
|
+
# Auto-reduce if exceeds max_parts
|
|
73
|
+
if num_parts > max_parts:
|
|
74
|
+
warnings.append(f"Image requires {num_parts} parts but max is {max_parts}. Reducing to {max_parts} parts with larger step.")
|
|
75
|
+
num_parts = max_parts
|
|
76
|
+
if num_parts > 1:
|
|
77
|
+
step = (height - tile_h) // (num_parts - 1)
|
|
78
|
+
else:
|
|
79
|
+
step = 0
|
|
80
|
+
|
|
81
|
+
return SplitPlan(
|
|
82
|
+
tile_width=effective_width,
|
|
83
|
+
tile_height=tile_h,
|
|
84
|
+
step_y=step,
|
|
85
|
+
num_parts=num_parts,
|
|
86
|
+
trim_width=trim_width,
|
|
87
|
+
warnings=warnings,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_and_normalize(data: bytes) -> Image.Image:
|
|
92
|
+
"""Load image from bytes, apply EXIF orientation, validate size."""
|
|
93
|
+
img = Image.open(BytesIO(data))
|
|
94
|
+
img.load()
|
|
95
|
+
|
|
96
|
+
if img.width * img.height > PIL_MAX_PIXELS:
|
|
97
|
+
raise ValueError(f"Image too large: {img.width}x{img.height} = {img.width * img.height:,} pixels (limit: {PIL_MAX_PIXELS:,})")
|
|
98
|
+
|
|
99
|
+
# Fix EXIF orientation (important for mobile photos)
|
|
100
|
+
img = ImageOps.exif_transpose(img)
|
|
101
|
+
return img
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def encode_jpeg(img: Image.Image, quality: int) -> bytes:
|
|
105
|
+
"""Encode PIL Image as JPEG bytes."""
|
|
106
|
+
# Convert to RGB if needed (JPEG doesn't support alpha)
|
|
107
|
+
if img.mode not in {"RGB", "L"}:
|
|
108
|
+
img = img.convert("RGB")
|
|
109
|
+
|
|
110
|
+
buf = BytesIO()
|
|
111
|
+
img.save(buf, format="JPEG", quality=quality, optimize=True)
|
|
112
|
+
return buf.getvalue()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def execute_split(
|
|
116
|
+
img: Image.Image,
|
|
117
|
+
plan: SplitPlan,
|
|
118
|
+
jpeg_quality: int,
|
|
119
|
+
) -> list[tuple[bytes, int, int, int, int]]:
|
|
120
|
+
"""Execute a split plan on an image.
|
|
121
|
+
|
|
122
|
+
Returns list of (data, width, height, source_y, source_height) tuples.
|
|
123
|
+
"""
|
|
124
|
+
width, height = img.size
|
|
125
|
+
|
|
126
|
+
# Trim width if needed (left-aligned crop)
|
|
127
|
+
if plan.trim_width is not None and width > plan.trim_width:
|
|
128
|
+
img = img.crop((0, 0, plan.trim_width, height))
|
|
129
|
+
width = plan.trim_width
|
|
130
|
+
|
|
131
|
+
# Convert to RGB once for JPEG
|
|
132
|
+
if img.mode not in {"RGB", "L"}:
|
|
133
|
+
img = img.convert("RGB")
|
|
134
|
+
|
|
135
|
+
parts: list[tuple[bytes, int, int, int, int]] = []
|
|
136
|
+
|
|
137
|
+
for i in range(plan.num_parts):
|
|
138
|
+
if plan.num_parts == 1:
|
|
139
|
+
y = 0
|
|
140
|
+
else:
|
|
141
|
+
y = i * plan.step_y
|
|
142
|
+
# Clamp so last tile aligns to bottom
|
|
143
|
+
y = min(y, max(0, height - plan.tile_height))
|
|
144
|
+
|
|
145
|
+
h = min(plan.tile_height, height - y)
|
|
146
|
+
tile = img.crop((0, y, width, y + h))
|
|
147
|
+
|
|
148
|
+
data = encode_jpeg(tile, jpeg_quality)
|
|
149
|
+
parts.append((data, width, h, y, h))
|
|
150
|
+
|
|
151
|
+
return parts
|
ai_pipeline_core/llm/__init__.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Large Language Model integration via LiteLLM proxy.
|
|
2
2
|
|
|
3
3
|
This package provides OpenAI API-compatible LLM interactions with built-in retry logic,
|
|
4
|
-
LMNR tracing, and structured output generation using Pydantic models.
|
|
4
|
+
LMNR tracing, and structured output generation using Pydantic models. Supports per-call
|
|
5
|
+
observability via purpose and expected_cost parameters for span naming and cost tracking.
|
|
5
6
|
"""
|
|
6
7
|
|
|
7
8
|
from .ai_messages import AIMessages, AIMessageType
|
|
@@ -10,15 +11,16 @@ from .client import (
|
|
|
10
11
|
generate_structured,
|
|
11
12
|
)
|
|
12
13
|
from .model_options import ModelOptions
|
|
13
|
-
from .model_response import ModelResponse, StructuredModelResponse
|
|
14
|
+
from .model_response import Citation, ModelResponse, StructuredModelResponse
|
|
14
15
|
from .model_types import ModelName
|
|
15
16
|
|
|
16
17
|
__all__ = [
|
|
17
|
-
"AIMessages",
|
|
18
18
|
"AIMessageType",
|
|
19
|
+
"AIMessages",
|
|
20
|
+
"Citation",
|
|
19
21
|
"ModelName",
|
|
20
|
-
"ModelResponse",
|
|
21
22
|
"ModelOptions",
|
|
23
|
+
"ModelResponse",
|
|
22
24
|
"StructuredModelResponse",
|
|
23
25
|
"generate",
|
|
24
26
|
"generate_structured",
|
|
@@ -1,33 +1,46 @@
|
|
|
1
1
|
"""AI message handling for LLM interactions.
|
|
2
2
|
|
|
3
|
-
@public
|
|
4
|
-
|
|
5
3
|
Provides AIMessages container for managing conversations with mixed content types
|
|
6
4
|
including text, documents, and model responses.
|
|
7
5
|
"""
|
|
8
6
|
|
|
9
7
|
import base64
|
|
10
8
|
import hashlib
|
|
9
|
+
import io
|
|
11
10
|
import json
|
|
11
|
+
from collections.abc import Callable, Iterable
|
|
12
12
|
from copy import deepcopy
|
|
13
|
-
from typing import Any,
|
|
13
|
+
from typing import Any, SupportsIndex
|
|
14
14
|
|
|
15
|
-
import tiktoken
|
|
16
15
|
from openai.types.chat import (
|
|
17
16
|
ChatCompletionContentPartParam,
|
|
18
17
|
ChatCompletionMessageParam,
|
|
19
18
|
)
|
|
20
|
-
from
|
|
19
|
+
from PIL import Image
|
|
21
20
|
|
|
22
21
|
from ai_pipeline_core.documents import Document
|
|
22
|
+
from ai_pipeline_core.documents.document import get_tiktoken_encoding
|
|
23
|
+
from ai_pipeline_core.documents.mime_type import is_llm_supported_image
|
|
24
|
+
from ai_pipeline_core.logging import get_pipeline_logger
|
|
23
25
|
|
|
24
26
|
from .model_response import ModelResponse
|
|
25
27
|
|
|
28
|
+
logger = get_pipeline_logger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ensure_llm_compatible_image(content: bytes, mime_type: str) -> tuple[bytes, str]:
|
|
32
|
+
"""Convert unsupported image formats to PNG for LLM consumption."""
|
|
33
|
+
if is_llm_supported_image(mime_type):
|
|
34
|
+
return content, mime_type
|
|
35
|
+
img = Image.open(io.BytesIO(content))
|
|
36
|
+
buf = io.BytesIO()
|
|
37
|
+
img.save(buf, format="PNG")
|
|
38
|
+
return buf.getvalue(), "image/png"
|
|
39
|
+
|
|
40
|
+
|
|
26
41
|
AIMessageType = str | Document | ModelResponse
|
|
27
42
|
"""Type for messages in AIMessages container.
|
|
28
43
|
|
|
29
|
-
@public
|
|
30
|
-
|
|
31
44
|
Represents the allowed types for conversation messages:
|
|
32
45
|
- str: Plain text messages
|
|
33
46
|
- Document: Structured document content
|
|
@@ -35,11 +48,9 @@ Represents the allowed types for conversation messages:
|
|
|
35
48
|
"""
|
|
36
49
|
|
|
37
50
|
|
|
38
|
-
class AIMessages(list[AIMessageType]):
|
|
51
|
+
class AIMessages(list[AIMessageType]): # noqa: PLR0904
|
|
39
52
|
"""Container for AI conversation messages supporting mixed types.
|
|
40
53
|
|
|
41
|
-
@public
|
|
42
|
-
|
|
43
54
|
This class extends list to manage conversation messages between user
|
|
44
55
|
and AI, supporting text, Document objects, and ModelResponse instances.
|
|
45
56
|
Messages are converted to OpenAI-compatible format for LLM interactions.
|
|
@@ -47,13 +58,14 @@ class AIMessages(list[AIMessageType]):
|
|
|
47
58
|
Conversion Rules:
|
|
48
59
|
- str: Becomes {"role": "user", "content": text}
|
|
49
60
|
- Document: Becomes {"role": "user", "content": document_content}
|
|
50
|
-
(automatically handles text, images, PDFs based on MIME type
|
|
61
|
+
(automatically handles text, images, PDFs based on MIME type; attachments
|
|
62
|
+
are rendered as <attachment> XML blocks)
|
|
51
63
|
- ModelResponse: Becomes {"role": "assistant", "content": response.content}
|
|
52
64
|
|
|
53
65
|
Note: Document conversion is automatic. Text content becomes user text messages.
|
|
54
66
|
|
|
55
67
|
VISION/PDF MODEL COMPATIBILITY WARNING:
|
|
56
|
-
Images require vision-capable models (e.g., gpt-
|
|
68
|
+
Images require vision-capable models (e.g., gpt-5.1, gemini-3-flash, gemini-3-pro).
|
|
57
69
|
Non-vision models will raise ValueError when encountering image documents.
|
|
58
70
|
PDFs require models with document processing support - check your model's capabilities
|
|
59
71
|
before including PDF documents in messages. Unsupported models may fall back to
|
|
@@ -70,12 +82,6 @@ class AIMessages(list[AIMessageType]):
|
|
|
70
82
|
constructor (`AIMessages("text")`) as this will raise a TypeError to prevent
|
|
71
83
|
accidental character iteration.
|
|
72
84
|
|
|
73
|
-
Example:
|
|
74
|
-
>>> from ai_pipeline_core import llm
|
|
75
|
-
>>> messages = AIMessages()
|
|
76
|
-
>>> messages.append("What is the capital of France?")
|
|
77
|
-
>>> response = await llm.generate("gpt-5", messages=messages)
|
|
78
|
-
>>> messages.append(response) # Add the actual response
|
|
79
85
|
"""
|
|
80
86
|
|
|
81
87
|
def __init__(self, iterable: Iterable[AIMessageType] | None = None, *, frozen: bool = False):
|
|
@@ -144,8 +150,8 @@ class AIMessages(list[AIMessageType]):
|
|
|
144
150
|
|
|
145
151
|
def __setitem__(
|
|
146
152
|
self,
|
|
147
|
-
index:
|
|
148
|
-
value:
|
|
153
|
+
index: SupportsIndex | slice,
|
|
154
|
+
value: AIMessageType | Iterable[AIMessageType],
|
|
149
155
|
) -> None:
|
|
150
156
|
"""Set item or slice."""
|
|
151
157
|
self._check_frozen()
|
|
@@ -160,7 +166,7 @@ class AIMessages(list[AIMessageType]):
|
|
|
160
166
|
self._check_frozen()
|
|
161
167
|
return super().__iadd__(other)
|
|
162
168
|
|
|
163
|
-
def __delitem__(self, index:
|
|
169
|
+
def __delitem__(self, index: SupportsIndex | slice) -> None:
|
|
164
170
|
"""Delete item or slice from list."""
|
|
165
171
|
self._check_frozen()
|
|
166
172
|
super().__delitem__(index)
|
|
@@ -189,9 +195,7 @@ class AIMessages(list[AIMessageType]):
|
|
|
189
195
|
self._check_frozen()
|
|
190
196
|
super().reverse()
|
|
191
197
|
|
|
192
|
-
def sort(
|
|
193
|
-
self, *, key: Callable[[AIMessageType], Any] | None = None, reverse: bool = False
|
|
194
|
-
) -> None:
|
|
198
|
+
def sort(self, *, key: Callable[[AIMessageType], Any] | None = None, reverse: bool = False) -> None:
|
|
195
199
|
"""Sort list in place."""
|
|
196
200
|
self._check_frozen()
|
|
197
201
|
if key is None:
|
|
@@ -238,6 +242,8 @@ class AIMessages(list[AIMessageType]):
|
|
|
238
242
|
|
|
239
243
|
Transforms the message list into the format expected by OpenAI API.
|
|
240
244
|
Each message type is converted according to its role and content.
|
|
245
|
+
Documents are rendered as XML with any attachments included as nested
|
|
246
|
+
<attachment> blocks.
|
|
241
247
|
|
|
242
248
|
Returns:
|
|
243
249
|
List of ChatCompletionMessageParam dicts (from openai.types.chat)
|
|
@@ -247,26 +253,40 @@ class AIMessages(list[AIMessageType]):
|
|
|
247
253
|
Raises:
|
|
248
254
|
ValueError: If message type is not supported.
|
|
249
255
|
|
|
250
|
-
Example:
|
|
251
|
-
>>> messages = AIMessages(["Hello", response, "Follow up"])
|
|
252
|
-
>>> prompt = messages.to_prompt()
|
|
253
|
-
>>> # Result: [
|
|
254
|
-
>>> # {"role": "user", "content": "Hello"},
|
|
255
|
-
>>> # {"role": "assistant", "content": "..."},
|
|
256
|
-
>>> # {"role": "user", "content": "Follow up"}
|
|
257
|
-
>>> # ]
|
|
258
256
|
"""
|
|
259
257
|
messages: list[ChatCompletionMessageParam] = []
|
|
260
258
|
|
|
261
259
|
for message in self:
|
|
262
260
|
if isinstance(message, str):
|
|
263
|
-
messages.append({"role": "user", "content": message})
|
|
261
|
+
messages.append({"role": "user", "content": [{"type": "text", "text": message}]})
|
|
264
262
|
elif isinstance(message, Document):
|
|
265
263
|
messages.append({"role": "user", "content": AIMessages.document_to_prompt(message)})
|
|
266
264
|
elif isinstance(message, ModelResponse): # type: ignore
|
|
267
|
-
|
|
265
|
+
# Build base assistant message
|
|
266
|
+
assistant_message: ChatCompletionMessageParam = {
|
|
267
|
+
"role": "assistant",
|
|
268
|
+
"content": [{"type": "text", "text": message.content}],
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
# Preserve reasoning_content (Gemini Flash 3+, O1, O3, GPT-5)
|
|
272
|
+
if reasoning_content := message.reasoning_content:
|
|
273
|
+
assistant_message["reasoning_content"] = reasoning_content # type: ignore[typeddict-item]
|
|
274
|
+
|
|
275
|
+
# Preserve thinking_blocks (structured thinking)
|
|
276
|
+
if hasattr(message.choices[0].message, "thinking_blocks"):
|
|
277
|
+
thinking_blocks = getattr(message.choices[0].message, "thinking_blocks", None)
|
|
278
|
+
if thinking_blocks:
|
|
279
|
+
assistant_message["thinking_blocks"] = thinking_blocks # type: ignore[typeddict-item]
|
|
280
|
+
|
|
281
|
+
# Preserve provider_specific_fields (thought_signatures for Gemini multi-turn)
|
|
282
|
+
if hasattr(message.choices[0].message, "provider_specific_fields"):
|
|
283
|
+
provider_fields = getattr(message.choices[0].message, "provider_specific_fields", None)
|
|
284
|
+
if provider_fields:
|
|
285
|
+
assistant_message["provider_specific_fields"] = provider_fields # type: ignore[typeddict-item]
|
|
286
|
+
|
|
287
|
+
messages.append(assistant_message)
|
|
268
288
|
else:
|
|
269
|
-
raise
|
|
289
|
+
raise TypeError(f"Unsupported message type: {type(message)}")
|
|
270
290
|
|
|
271
291
|
return messages
|
|
272
292
|
|
|
@@ -306,8 +326,6 @@ class AIMessages(list[AIMessageType]):
|
|
|
306
326
|
def approximate_tokens_count(self) -> int:
|
|
307
327
|
"""Approximate tokens count for the messages.
|
|
308
328
|
|
|
309
|
-
@public
|
|
310
|
-
|
|
311
329
|
Uses tiktoken with gpt-4 encoding to estimate total token count
|
|
312
330
|
across all messages in the conversation.
|
|
313
331
|
|
|
@@ -317,26 +335,27 @@ class AIMessages(list[AIMessageType]):
|
|
|
317
335
|
Raises:
|
|
318
336
|
ValueError: If message contains unsupported type.
|
|
319
337
|
|
|
320
|
-
Example:
|
|
321
|
-
>>> messages = AIMessages(["Hello", "World"])
|
|
322
|
-
>>> messages.approximate_tokens_count # ~2-3 tokens
|
|
323
338
|
"""
|
|
324
339
|
count = 0
|
|
340
|
+
enc = get_tiktoken_encoding()
|
|
325
341
|
for message in self:
|
|
326
342
|
if isinstance(message, str):
|
|
327
|
-
count += len(
|
|
343
|
+
count += len(enc.encode(message))
|
|
328
344
|
elif isinstance(message, Document):
|
|
329
345
|
count += message.approximate_tokens_count
|
|
330
346
|
elif isinstance(message, ModelResponse): # type: ignore
|
|
331
|
-
count += len(
|
|
347
|
+
count += len(enc.encode(message.content))
|
|
332
348
|
else:
|
|
333
|
-
raise
|
|
349
|
+
raise TypeError(f"Unsupported message type: {type(message)}")
|
|
334
350
|
return count
|
|
335
351
|
|
|
336
352
|
@staticmethod
|
|
337
|
-
def document_to_prompt(document: Document) -> list[ChatCompletionContentPartParam]:
|
|
353
|
+
def document_to_prompt(document: Document) -> list[ChatCompletionContentPartParam]: # noqa: PLR0912, PLR0914
|
|
338
354
|
"""Convert a document to prompt format for LLM consumption.
|
|
339
355
|
|
|
356
|
+
Renders the document as XML with text/image/PDF content, followed by any
|
|
357
|
+
attachments as separate <attachment> XML blocks with name and description attributes.
|
|
358
|
+
|
|
340
359
|
Args:
|
|
341
360
|
document: The document to convert.
|
|
342
361
|
|
|
@@ -346,50 +365,80 @@ class AIMessages(list[AIMessageType]):
|
|
|
346
365
|
prompt: list[ChatCompletionContentPartParam] = []
|
|
347
366
|
|
|
348
367
|
# Build the text header
|
|
349
|
-
description =
|
|
350
|
-
|
|
351
|
-
)
|
|
352
|
-
header_text = (
|
|
353
|
-
f"<document>\n<id>{document.id}</id>\n<name>{document.name}</name>\n{description}"
|
|
354
|
-
)
|
|
368
|
+
description = f"<description>{document.description}</description>\n" if document.description else ""
|
|
369
|
+
header_text = f"<document>\n<id>{document.id}</id>\n<name>{document.name}</name>\n{description}"
|
|
355
370
|
|
|
356
371
|
# Handle text documents
|
|
357
372
|
if document.is_text:
|
|
358
373
|
text_content = document.content.decode("utf-8")
|
|
359
|
-
content_text = f"{header_text}<content>\n{text_content}\n</content>\n
|
|
374
|
+
content_text = f"{header_text}<content>\n{text_content}\n</content>\n"
|
|
360
375
|
prompt.append({"type": "text", "text": content_text})
|
|
361
|
-
return prompt
|
|
362
376
|
|
|
363
|
-
# Handle
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
377
|
+
# Handle binary documents (image/PDF)
|
|
378
|
+
elif document.is_image or document.is_pdf:
|
|
379
|
+
prompt.append({"type": "text", "text": f"{header_text}<content>\n"})
|
|
380
|
+
|
|
381
|
+
if document.is_image:
|
|
382
|
+
content_bytes, mime_type = _ensure_llm_compatible_image(document.content, document.mime_type)
|
|
383
|
+
else:
|
|
384
|
+
content_bytes, mime_type = document.content, document.mime_type
|
|
385
|
+
base64_content = base64.b64encode(content_bytes).decode("utf-8")
|
|
386
|
+
data_uri = f"data:{mime_type};base64,{base64_content}"
|
|
387
|
+
|
|
388
|
+
if document.is_pdf:
|
|
389
|
+
prompt.append({
|
|
390
|
+
"type": "file",
|
|
391
|
+
"file": {"file_data": data_uri},
|
|
392
|
+
})
|
|
393
|
+
else:
|
|
394
|
+
prompt.append({
|
|
395
|
+
"type": "image_url",
|
|
396
|
+
"image_url": {"url": data_uri, "detail": "high"},
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
prompt.append({"type": "text", "text": "</content>\n"})
|
|
400
|
+
|
|
401
|
+
else:
|
|
402
|
+
logger.error(f"Document is not a text, image or PDF: {document.name} - {document.mime_type}")
|
|
368
403
|
return []
|
|
369
404
|
|
|
370
|
-
#
|
|
371
|
-
|
|
372
|
-
"
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
405
|
+
# Render attachments
|
|
406
|
+
for att in document.attachments:
|
|
407
|
+
desc_attr = f' description="{att.description}"' if att.description else ""
|
|
408
|
+
att_open = f'<attachment name="{att.name}"{desc_attr}>\n'
|
|
409
|
+
|
|
410
|
+
if att.is_text:
|
|
411
|
+
prompt.append({"type": "text", "text": f"{att_open}{att.text}\n</attachment>\n"})
|
|
412
|
+
elif att.is_image or att.is_pdf:
|
|
413
|
+
prompt.append({"type": "text", "text": att_open})
|
|
414
|
+
|
|
415
|
+
if att.is_image:
|
|
416
|
+
att_bytes, att_mime = _ensure_llm_compatible_image(att.content, att.mime_type)
|
|
417
|
+
else:
|
|
418
|
+
att_bytes, att_mime = att.content, att.mime_type
|
|
419
|
+
att_b64 = base64.b64encode(att_bytes).decode("utf-8")
|
|
420
|
+
att_uri = f"data:{att_mime};base64,{att_b64}"
|
|
421
|
+
|
|
422
|
+
if att.is_pdf:
|
|
423
|
+
prompt.append({
|
|
424
|
+
"type": "file",
|
|
425
|
+
"file": {"file_data": att_uri},
|
|
426
|
+
})
|
|
427
|
+
else:
|
|
428
|
+
prompt.append({
|
|
429
|
+
"type": "image_url",
|
|
430
|
+
"image_url": {"url": att_uri, "detail": "high"},
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
prompt.append({"type": "text", "text": "</attachment>\n"})
|
|
434
|
+
else:
|
|
435
|
+
logger.warning(f"Skipping unsupported attachment type: {att.name} - {att.mime_type}")
|
|
436
|
+
|
|
437
|
+
# Close document — merge into last text part to preserve JSON structure (and cache key)
|
|
438
|
+
last = prompt[-1]
|
|
439
|
+
if last["type"] == "text":
|
|
440
|
+
prompt[-1] = {"type": "text", "text": last["text"] + "</document>\n"}
|
|
441
|
+
else:
|
|
442
|
+
prompt.append({"type": "text", "text": "</document>\n"})
|
|
394
443
|
|
|
395
444
|
return prompt
|