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.
Files changed (94) hide show
  1. ai_pipeline_core/__init__.py +78 -125
  2. ai_pipeline_core/deployment/__init__.py +34 -0
  3. ai_pipeline_core/deployment/base.py +861 -0
  4. ai_pipeline_core/deployment/contract.py +80 -0
  5. ai_pipeline_core/deployment/deploy.py +561 -0
  6. ai_pipeline_core/deployment/helpers.py +97 -0
  7. ai_pipeline_core/deployment/progress.py +126 -0
  8. ai_pipeline_core/deployment/remote.py +116 -0
  9. ai_pipeline_core/docs_generator/__init__.py +54 -0
  10. ai_pipeline_core/docs_generator/__main__.py +5 -0
  11. ai_pipeline_core/docs_generator/cli.py +196 -0
  12. ai_pipeline_core/docs_generator/extractor.py +324 -0
  13. ai_pipeline_core/docs_generator/guide_builder.py +644 -0
  14. ai_pipeline_core/docs_generator/trimmer.py +35 -0
  15. ai_pipeline_core/docs_generator/validator.py +114 -0
  16. ai_pipeline_core/document_store/__init__.py +13 -0
  17. ai_pipeline_core/document_store/_summary.py +9 -0
  18. ai_pipeline_core/document_store/_summary_worker.py +170 -0
  19. ai_pipeline_core/document_store/clickhouse.py +492 -0
  20. ai_pipeline_core/document_store/factory.py +38 -0
  21. ai_pipeline_core/document_store/local.py +312 -0
  22. ai_pipeline_core/document_store/memory.py +85 -0
  23. ai_pipeline_core/document_store/protocol.py +68 -0
  24. ai_pipeline_core/documents/__init__.py +12 -14
  25. ai_pipeline_core/documents/_context_vars.py +85 -0
  26. ai_pipeline_core/documents/_hashing.py +52 -0
  27. ai_pipeline_core/documents/attachment.py +85 -0
  28. ai_pipeline_core/documents/context.py +128 -0
  29. ai_pipeline_core/documents/document.py +318 -1434
  30. ai_pipeline_core/documents/mime_type.py +37 -82
  31. ai_pipeline_core/documents/utils.py +4 -12
  32. ai_pipeline_core/exceptions.py +10 -62
  33. ai_pipeline_core/images/__init__.py +309 -0
  34. ai_pipeline_core/images/_processing.py +151 -0
  35. ai_pipeline_core/llm/__init__.py +6 -4
  36. ai_pipeline_core/llm/ai_messages.py +130 -81
  37. ai_pipeline_core/llm/client.py +327 -193
  38. ai_pipeline_core/llm/model_options.py +14 -86
  39. ai_pipeline_core/llm/model_response.py +60 -103
  40. ai_pipeline_core/llm/model_types.py +16 -34
  41. ai_pipeline_core/logging/__init__.py +2 -7
  42. ai_pipeline_core/logging/logging.yml +1 -1
  43. ai_pipeline_core/logging/logging_config.py +27 -37
  44. ai_pipeline_core/logging/logging_mixin.py +15 -41
  45. ai_pipeline_core/observability/__init__.py +32 -0
  46. ai_pipeline_core/observability/_debug/__init__.py +30 -0
  47. ai_pipeline_core/observability/_debug/_auto_summary.py +94 -0
  48. ai_pipeline_core/observability/_debug/_config.py +95 -0
  49. ai_pipeline_core/observability/_debug/_content.py +764 -0
  50. ai_pipeline_core/observability/_debug/_processor.py +98 -0
  51. ai_pipeline_core/observability/_debug/_summary.py +312 -0
  52. ai_pipeline_core/observability/_debug/_types.py +75 -0
  53. ai_pipeline_core/observability/_debug/_writer.py +843 -0
  54. ai_pipeline_core/observability/_document_tracking.py +146 -0
  55. ai_pipeline_core/observability/_initialization.py +194 -0
  56. ai_pipeline_core/observability/_logging_bridge.py +57 -0
  57. ai_pipeline_core/observability/_summary.py +81 -0
  58. ai_pipeline_core/observability/_tracking/__init__.py +6 -0
  59. ai_pipeline_core/observability/_tracking/_client.py +178 -0
  60. ai_pipeline_core/observability/_tracking/_internal.py +28 -0
  61. ai_pipeline_core/observability/_tracking/_models.py +138 -0
  62. ai_pipeline_core/observability/_tracking/_processor.py +158 -0
  63. ai_pipeline_core/observability/_tracking/_service.py +311 -0
  64. ai_pipeline_core/observability/_tracking/_writer.py +229 -0
  65. ai_pipeline_core/{tracing.py → observability/tracing.py} +139 -283
  66. ai_pipeline_core/pipeline/__init__.py +10 -0
  67. ai_pipeline_core/pipeline/decorators.py +915 -0
  68. ai_pipeline_core/pipeline/options.py +16 -0
  69. ai_pipeline_core/prompt_manager.py +16 -102
  70. ai_pipeline_core/settings.py +26 -31
  71. ai_pipeline_core/testing.py +9 -0
  72. ai_pipeline_core-0.4.1.dist-info/METADATA +807 -0
  73. ai_pipeline_core-0.4.1.dist-info/RECORD +76 -0
  74. {ai_pipeline_core-0.2.6.dist-info → ai_pipeline_core-0.4.1.dist-info}/WHEEL +1 -1
  75. ai_pipeline_core/documents/document_list.py +0 -420
  76. ai_pipeline_core/documents/flow_document.py +0 -112
  77. ai_pipeline_core/documents/task_document.py +0 -117
  78. ai_pipeline_core/documents/temporary_document.py +0 -74
  79. ai_pipeline_core/flow/__init__.py +0 -9
  80. ai_pipeline_core/flow/config.py +0 -483
  81. ai_pipeline_core/flow/options.py +0 -75
  82. ai_pipeline_core/pipeline.py +0 -718
  83. ai_pipeline_core/prefect.py +0 -63
  84. ai_pipeline_core/simple_runner/__init__.py +0 -14
  85. ai_pipeline_core/simple_runner/cli.py +0 -254
  86. ai_pipeline_core/simple_runner/simple_runner.py +0 -247
  87. ai_pipeline_core/storage/__init__.py +0 -8
  88. ai_pipeline_core/storage/storage.py +0 -628
  89. ai_pipeline_core/utils/__init__.py +0 -8
  90. ai_pipeline_core/utils/deploy.py +0 -373
  91. ai_pipeline_core/utils/remote_deployment.py +0 -269
  92. ai_pipeline_core-0.2.6.dist-info/METADATA +0 -500
  93. ai_pipeline_core-0.2.6.dist-info/RECORD +0 -41
  94. {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
@@ -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, Callable, Iterable, SupportsIndex, Union
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 prefect.logging import get_logger
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-4o, gemini-pro-vision, claude-3-haiku).
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: Union[SupportsIndex, slice],
148
- value: Union[AIMessageType, Iterable[AIMessageType]],
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: Union[SupportsIndex, slice]) -> None:
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
- messages.append({"role": "assistant", "content": message.content})
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 ValueError(f"Unsupported message type: {type(message)}")
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(tiktoken.encoding_for_model("gpt-4").encode(message))
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(tiktoken.encoding_for_model("gpt-4").encode(message.content))
347
+ count += len(enc.encode(message.content))
332
348
  else:
333
- raise ValueError(f"Unsupported message type: {type(message)}")
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
- f"<description>{document.description}</description>\n" if document.description else ""
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</document>\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 non-text documents
364
- if not document.is_image and not document.is_pdf:
365
- get_logger(__name__).error(
366
- f"Document is not a text, image or PDF: {document.name} - {document.mime_type}"
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
- # Add header for binary content
371
- prompt.append({
372
- "type": "text",
373
- "text": f"{header_text}<content>\n",
374
- })
375
-
376
- # Encode binary content
377
- base64_content = base64.b64encode(document.content).decode("utf-8")
378
- data_uri = f"data:{document.mime_type};base64,{base64_content}"
379
-
380
- # Add appropriate content type
381
- if document.is_pdf:
382
- prompt.append({
383
- "type": "file",
384
- "file": {"file_data": data_uri},
385
- })
386
- else: # is_image
387
- prompt.append({
388
- "type": "image_url",
389
- "image_url": {"url": data_uri, "detail": "high"},
390
- })
391
-
392
- # Close the document tag
393
- prompt.append({"type": "text", "text": "</content>\n</document>\n"})
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