ai-pipeline-core 0.2.9__py3-none-any.whl → 0.3.3__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 (42) hide show
  1. ai_pipeline_core/__init__.py +32 -5
  2. ai_pipeline_core/debug/__init__.py +26 -0
  3. ai_pipeline_core/debug/config.py +91 -0
  4. ai_pipeline_core/debug/content.py +705 -0
  5. ai_pipeline_core/debug/processor.py +99 -0
  6. ai_pipeline_core/debug/summary.py +236 -0
  7. ai_pipeline_core/debug/writer.py +913 -0
  8. ai_pipeline_core/deployment/__init__.py +46 -0
  9. ai_pipeline_core/deployment/base.py +681 -0
  10. ai_pipeline_core/deployment/contract.py +84 -0
  11. ai_pipeline_core/deployment/helpers.py +98 -0
  12. ai_pipeline_core/documents/flow_document.py +1 -1
  13. ai_pipeline_core/documents/task_document.py +1 -1
  14. ai_pipeline_core/documents/temporary_document.py +1 -1
  15. ai_pipeline_core/flow/config.py +13 -2
  16. ai_pipeline_core/flow/options.py +4 -4
  17. ai_pipeline_core/images/__init__.py +362 -0
  18. ai_pipeline_core/images/_processing.py +157 -0
  19. ai_pipeline_core/llm/ai_messages.py +25 -4
  20. ai_pipeline_core/llm/client.py +15 -19
  21. ai_pipeline_core/llm/model_response.py +5 -5
  22. ai_pipeline_core/llm/model_types.py +10 -13
  23. ai_pipeline_core/logging/logging_mixin.py +2 -2
  24. ai_pipeline_core/pipeline.py +1 -1
  25. ai_pipeline_core/progress.py +127 -0
  26. ai_pipeline_core/prompt_builder/__init__.py +5 -0
  27. ai_pipeline_core/prompt_builder/documents_prompt.jinja2 +23 -0
  28. ai_pipeline_core/prompt_builder/global_cache.py +78 -0
  29. ai_pipeline_core/prompt_builder/new_core_documents_prompt.jinja2 +6 -0
  30. ai_pipeline_core/prompt_builder/prompt_builder.py +253 -0
  31. ai_pipeline_core/prompt_builder/system_prompt.jinja2 +41 -0
  32. ai_pipeline_core/tracing.py +54 -2
  33. ai_pipeline_core/utils/deploy.py +214 -6
  34. ai_pipeline_core/utils/remote_deployment.py +37 -187
  35. {ai_pipeline_core-0.2.9.dist-info → ai_pipeline_core-0.3.3.dist-info}/METADATA +96 -27
  36. ai_pipeline_core-0.3.3.dist-info/RECORD +57 -0
  37. {ai_pipeline_core-0.2.9.dist-info → ai_pipeline_core-0.3.3.dist-info}/WHEEL +1 -1
  38. ai_pipeline_core/simple_runner/__init__.py +0 -14
  39. ai_pipeline_core/simple_runner/cli.py +0 -254
  40. ai_pipeline_core/simple_runner/simple_runner.py +0 -247
  41. ai_pipeline_core-0.2.9.dist-info/RECORD +0 -41
  42. {ai_pipeline_core-0.2.9.dist-info → ai_pipeline_core-0.3.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,84 @@
1
+ """Unified pipeline run response contract.
2
+
3
+ @public
4
+
5
+ Single source of truth for the response shape used by both
6
+ webhook push (ai-pipeline-core) and polling pull (unified-middleware).
7
+ """
8
+
9
+ from datetime import datetime
10
+ from typing import Annotated, Literal
11
+ from uuid import UUID
12
+
13
+ from pydantic import BaseModel, ConfigDict, Discriminator
14
+
15
+
16
+ class _RunBase(BaseModel):
17
+ """Common fields on every run response variant."""
18
+
19
+ type: str
20
+ flow_run_id: UUID
21
+ project_name: str
22
+ state: str # PENDING, RUNNING, COMPLETED, FAILED, CRASHED, CANCELLED
23
+ timestamp: datetime
24
+ storage_uri: str = ""
25
+
26
+ model_config = ConfigDict(frozen=True)
27
+
28
+
29
+ class PendingRun(_RunBase):
30
+ """Pipeline queued or running but no progress reported yet."""
31
+
32
+ type: Literal["pending"] = "pending" # pyright: ignore[reportIncompatibleVariableOverride]
33
+
34
+
35
+ class ProgressRun(_RunBase):
36
+ """Pipeline running with step-level progress data."""
37
+
38
+ type: Literal["progress"] = "progress" # pyright: ignore[reportIncompatibleVariableOverride]
39
+ step: int
40
+ total_steps: int
41
+ flow_name: str
42
+ status: str # "started", "completed", "cached"
43
+ progress: float # overall 0.0–1.0
44
+ step_progress: float # within step 0.0–1.0
45
+ message: str
46
+
47
+
48
+ class DeploymentResultData(BaseModel):
49
+ """Typed result payload — always has success + optional error."""
50
+
51
+ success: bool
52
+ error: str | None = None
53
+
54
+ model_config = ConfigDict(frozen=True, extra="allow")
55
+
56
+
57
+ class CompletedRun(_RunBase):
58
+ """Pipeline finished (Prefect COMPLETED). Check result.success for business outcome."""
59
+
60
+ type: Literal["completed"] = "completed" # pyright: ignore[reportIncompatibleVariableOverride]
61
+ result: DeploymentResultData
62
+
63
+
64
+ class FailedRun(_RunBase):
65
+ """Pipeline crashed — execution error, not business logic."""
66
+
67
+ type: Literal["failed"] = "failed" # pyright: ignore[reportIncompatibleVariableOverride]
68
+ error: str
69
+ result: DeploymentResultData | None = None
70
+
71
+
72
+ RunResponse = Annotated[
73
+ PendingRun | ProgressRun | CompletedRun | FailedRun,
74
+ Discriminator("type"),
75
+ ]
76
+
77
+ __all__ = [
78
+ "CompletedRun",
79
+ "DeploymentResultData",
80
+ "FailedRun",
81
+ "PendingRun",
82
+ "ProgressRun",
83
+ "RunResponse",
84
+ ]
@@ -0,0 +1,98 @@
1
+ """Helper functions for pipeline deployments."""
2
+
3
+ import asyncio
4
+ import re
5
+ from typing import Any, Literal, TypedDict
6
+
7
+ import httpx
8
+
9
+ from ai_pipeline_core.deployment.contract import CompletedRun, FailedRun, ProgressRun
10
+ from ai_pipeline_core.documents import Document, DocumentList, FlowDocument
11
+ from ai_pipeline_core.logging import get_pipeline_logger
12
+
13
+ logger = get_pipeline_logger(__name__)
14
+
15
+
16
+ class StatusPayload(TypedDict):
17
+ """Webhook payload for Prefect state transitions (sub-flow level)."""
18
+
19
+ type: Literal["status"]
20
+ flow_run_id: str
21
+ project_name: str
22
+ step: int
23
+ total_steps: int
24
+ flow_name: str
25
+ state: str # RUNNING, COMPLETED, FAILED, CRASHED, CANCELLED
26
+ state_name: str
27
+ timestamp: str
28
+
29
+
30
+ def class_name_to_deployment_name(class_name: str) -> str:
31
+ """Convert PascalCase to kebab-case: ResearchPipeline → research-pipeline."""
32
+ name = re.sub(r"(?<!^)(?=[A-Z])", "-", class_name)
33
+ return name.lower()
34
+
35
+
36
+ def extract_generic_params(cls: type) -> tuple[type | None, type | None]:
37
+ """Extract TOptions and TResult from PipelineDeployment generic args."""
38
+ from ai_pipeline_core.deployment.base import PipelineDeployment # noqa: PLC0415
39
+
40
+ for base in getattr(cls, "__orig_bases__", []):
41
+ origin = getattr(base, "__origin__", None)
42
+ if origin is PipelineDeployment:
43
+ args = getattr(base, "__args__", ())
44
+ if len(args) == 2:
45
+ return args[0], args[1]
46
+
47
+ return None, None
48
+
49
+
50
+ async def download_documents(
51
+ urls: list[str],
52
+ document_type: type[FlowDocument],
53
+ ) -> DocumentList:
54
+ """Download documents from URLs and return as DocumentList."""
55
+ documents: list[Document] = []
56
+ async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
57
+ for url in urls:
58
+ response = await client.get(url)
59
+ response.raise_for_status()
60
+ filename = url.split("/")[-1].split("?")[0] or "document"
61
+ documents.append(document_type(name=filename, content=response.content))
62
+ return DocumentList(documents)
63
+
64
+
65
+ async def upload_documents(documents: DocumentList, url_mapping: dict[str, str]) -> None:
66
+ """Upload documents to their mapped URLs."""
67
+ async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
68
+ for doc in documents:
69
+ if doc.name in url_mapping:
70
+ response = await client.put(
71
+ url_mapping[doc.name],
72
+ content=doc.content,
73
+ headers={"Content-Type": doc.mime_type},
74
+ )
75
+ response.raise_for_status()
76
+
77
+
78
+ async def send_webhook(
79
+ url: str,
80
+ payload: ProgressRun | CompletedRun | FailedRun,
81
+ max_retries: int = 3,
82
+ retry_delay: float = 10.0,
83
+ ) -> None:
84
+ """Send webhook with retries."""
85
+ data: dict[str, Any] = payload.model_dump(mode="json")
86
+ for attempt in range(max_retries):
87
+ try:
88
+ async with httpx.AsyncClient(timeout=30) as client:
89
+ response = await client.post(url, json=data, follow_redirects=True)
90
+ response.raise_for_status()
91
+ return
92
+ except Exception as e:
93
+ if attempt < max_retries - 1:
94
+ logger.warning(f"Webhook retry {attempt + 1}/{max_retries}: {e}")
95
+ await asyncio.sleep(retry_delay)
96
+ else:
97
+ logger.error(f"Webhook failed after {max_retries} attempts: {e}")
98
+ raise
@@ -18,7 +18,7 @@ class FlowDocument(Document):
18
18
 
19
19
  FlowDocument is used for data that needs to be saved between pipeline
20
20
  steps and across multiple flow executions. These documents are typically
21
- written to the file system using the simple_runner utilities.
21
+ written to the file system using the deployment utilities.
22
22
 
23
23
  Key characteristics:
24
24
  - Persisted to file system between pipeline steps
@@ -40,7 +40,7 @@ class TaskDocument(Document):
40
40
 
41
41
  Note:
42
42
  - Cannot instantiate TaskDocument directly - must subclass
43
- - Not saved by simple_runner utilities
43
+ - Not saved by deployment utilities
44
44
  - Reduces I/O overhead for temporary data
45
45
  - No additional abstract methods to implement
46
46
  """
@@ -23,7 +23,7 @@ class TemporaryDocument(Document):
23
23
  - Can be instantiated directly (not abstract)
24
24
  - Cannot be subclassed (annotated with Python's @final decorator in code)
25
25
  - Useful for transient data like API responses or intermediate calculations
26
- - Ignored by simple_runner save operations
26
+ - Ignored by deployment save operations
27
27
  - Useful for tests and debugging
28
28
 
29
29
  Creating TemporaryDocuments:
@@ -39,11 +39,13 @@ class FlowConfig(ABC):
39
39
  Class Variables:
40
40
  INPUT_DOCUMENT_TYPES: List of FlowDocument types this flow accepts
41
41
  OUTPUT_DOCUMENT_TYPE: Single FlowDocument type this flow produces
42
+ WEIGHT: Weight for progress calculation (default 1.0, based on avg duration)
42
43
 
43
44
  Validation Rules:
44
45
  - INPUT_DOCUMENT_TYPES and OUTPUT_DOCUMENT_TYPE must be defined
45
46
  - OUTPUT_DOCUMENT_TYPE cannot be in INPUT_DOCUMENT_TYPES (prevents cycles)
46
47
  - Field names must be exact (common typos are detected)
48
+ - WEIGHT must be a positive number
47
49
 
48
50
  Why this matters:
49
51
  Flows connect in pipelines where one flow's output becomes another's input.
@@ -54,6 +56,7 @@ class FlowConfig(ABC):
54
56
  >>> class ProcessingFlowConfig(FlowConfig):
55
57
  ... INPUT_DOCUMENT_TYPES = [RawDataDocument]
56
58
  ... OUTPUT_DOCUMENT_TYPE = ProcessedDocument # Different type!
59
+ ... WEIGHT = 45.0 # Average ~45 minutes
57
60
  >>>
58
61
  >>> # Use in @pipeline_flow - RECOMMENDED PATTERN
59
62
  >>> @pipeline_flow(config=ProcessingFlowConfig, name="processing")
@@ -72,11 +75,12 @@ class FlowConfig(ABC):
72
75
  Note:
73
76
  - Validation happens at class definition time
74
77
  - Helps catch configuration errors early
75
- - Used by simple_runner to manage document flow
78
+ - Used by PipelineDeployment to manage document flow
76
79
  """
77
80
 
78
81
  INPUT_DOCUMENT_TYPES: ClassVar[list[type[FlowDocument]]]
79
82
  OUTPUT_DOCUMENT_TYPE: ClassVar[type[FlowDocument]]
83
+ WEIGHT: ClassVar[float] = 1.0
80
84
 
81
85
  def __init_subclass__(cls, **kwargs: Any):
82
86
  """Validate flow configuration at subclass definition time.
@@ -106,7 +110,7 @@ class FlowConfig(ABC):
106
110
  return
107
111
 
108
112
  # Check for invalid field names (common mistakes)
109
- allowed_fields = {"INPUT_DOCUMENT_TYPES", "OUTPUT_DOCUMENT_TYPE"}
113
+ allowed_fields = {"INPUT_DOCUMENT_TYPES", "OUTPUT_DOCUMENT_TYPE", "WEIGHT"}
110
114
  class_attrs = {name for name in dir(cls) if not name.startswith("_") and name.isupper()}
111
115
 
112
116
  # Find fields that look like they might be mistakes
@@ -145,6 +149,13 @@ class FlowConfig(ABC):
145
149
  f"({cls.OUTPUT_DOCUMENT_TYPE.__name__}) cannot be in INPUT_DOCUMENT_TYPES"
146
150
  )
147
151
 
152
+ # Validate WEIGHT
153
+ weight = getattr(cls, "WEIGHT", 1.0)
154
+ if not isinstance(weight, (int, float)) or weight <= 0:
155
+ raise TypeError(
156
+ f"FlowConfig {cls.__name__}: WEIGHT must be a positive number, got {weight}"
157
+ )
158
+
148
159
  @classmethod
149
160
  def get_input_document_types(cls) -> list[type[FlowDocument]]:
150
161
  """Get the list of input document types this flow accepts.
@@ -41,7 +41,7 @@ class FlowOptions(BaseSettings):
41
41
 
42
42
  >>> # Or create programmatically:
43
43
  >>> options = MyFlowOptions(
44
- ... core_model="gemini-2.5-pro",
44
+ ... core_model="gemini-3-pro",
45
45
  ... temperature=0.9
46
46
  ... )
47
47
 
@@ -53,7 +53,7 @@ class FlowOptions(BaseSettings):
53
53
  - Frozen (immutable) after creation
54
54
  - Extra fields ignored (not strict)
55
55
  - Can be populated from environment variables
56
- - Used by simple_runner.cli for command-line parsing
56
+ - Used by PipelineDeployment.run_cli for command-line parsing
57
57
 
58
58
  Note:
59
59
  The base class provides model selection. Subclasses should
@@ -61,11 +61,11 @@ class FlowOptions(BaseSettings):
61
61
  """
62
62
 
63
63
  core_model: ModelName = Field(
64
- default="gemini-2.5-pro",
64
+ default="gemini-3-pro",
65
65
  description="Primary model for complex analysis and generation tasks.",
66
66
  )
67
67
  small_model: ModelName = Field(
68
- default="grok-4-fast",
68
+ default="grok-4.1-fast",
69
69
  description="Fast, cost-effective model for simple tasks and orchestration.",
70
70
  )
71
71
 
@@ -0,0 +1,362 @@
1
+ """Image processing utilities for LLM vision models.
2
+
3
+ @public
4
+
5
+ Splits large images, compresses to JPEG, and respects model-specific constraints.
6
+ Designed for website screenshots, document pages, and other visual content
7
+ sent to vision-capable LLMs.
8
+
9
+ Quick Start:
10
+ >>> from ai_pipeline_core.images import process_image, ImagePreset
11
+ >>>
12
+ >>> result = process_image(screenshot_bytes)
13
+ >>> for part in result:
14
+ ... send_to_llm(part.data, context=part.label)
15
+ >>>
16
+ >>> result = process_image(screenshot_bytes, preset=ImagePreset.GEMINI)
17
+ """
18
+
19
+ from enum import StrEnum
20
+
21
+ from pydantic import BaseModel, Field
22
+
23
+ from ai_pipeline_core.documents import Document, TemporaryDocument
24
+
25
+ from ._processing import execute_split, load_and_normalize, plan_split
26
+
27
+ __all__ = [
28
+ "ImagePreset",
29
+ "ImageProcessingConfig",
30
+ "ImagePart",
31
+ "ProcessedImage",
32
+ "ImageProcessingError",
33
+ "process_image",
34
+ "process_image_to_documents",
35
+ ]
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Configuration
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ class ImagePreset(StrEnum):
44
+ """Presets for LLM vision model constraints.
45
+
46
+ @public
47
+ """
48
+
49
+ GEMINI = "gemini"
50
+ CLAUDE = "claude"
51
+ GPT4V = "gpt4v"
52
+
53
+
54
+ class ImageProcessingConfig(BaseModel):
55
+ """Configuration for image processing.
56
+
57
+ @public
58
+
59
+ Use ``for_preset`` for standard configurations or construct directly for
60
+ custom constraints.
61
+
62
+ Example:
63
+ >>> config = ImageProcessingConfig.for_preset(ImagePreset.GEMINI)
64
+ >>> config = ImageProcessingConfig(max_dimension=2000, jpeg_quality=80)
65
+ """
66
+
67
+ model_config = {"frozen": True}
68
+
69
+ max_dimension: int = Field(
70
+ default=3000,
71
+ ge=100,
72
+ le=8192,
73
+ description="Maximum width AND height in pixels",
74
+ )
75
+ max_pixels: int = Field(
76
+ default=9_000_000,
77
+ ge=10_000,
78
+ description="Maximum total pixels per output image part",
79
+ )
80
+ overlap_fraction: float = Field(
81
+ default=0.20,
82
+ ge=0.0,
83
+ le=0.5,
84
+ description="Overlap between adjacent vertical parts (0.0-0.5)",
85
+ )
86
+ max_parts: int = Field(
87
+ default=20,
88
+ ge=1,
89
+ le=100,
90
+ description="Maximum number of output image parts",
91
+ )
92
+ jpeg_quality: int = Field(
93
+ default=60,
94
+ ge=10,
95
+ le=95,
96
+ description="JPEG compression quality (10-95)",
97
+ )
98
+
99
+ @classmethod
100
+ def for_preset(cls, preset: ImagePreset) -> "ImageProcessingConfig":
101
+ """Create configuration from a model preset.
102
+
103
+ @public
104
+ """
105
+ return _PRESETS[preset]
106
+
107
+
108
+ _PRESETS: dict[ImagePreset, ImageProcessingConfig] = {
109
+ ImagePreset.GEMINI: ImageProcessingConfig(
110
+ max_dimension=3000,
111
+ max_pixels=9_000_000,
112
+ jpeg_quality=75,
113
+ ),
114
+ ImagePreset.CLAUDE: ImageProcessingConfig(
115
+ max_dimension=1568,
116
+ max_pixels=1_150_000,
117
+ jpeg_quality=60,
118
+ ),
119
+ ImagePreset.GPT4V: ImageProcessingConfig(
120
+ max_dimension=2048,
121
+ max_pixels=4_000_000,
122
+ jpeg_quality=70,
123
+ ),
124
+ }
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Result models
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ class ImagePart(BaseModel):
133
+ """A single processed image part.
134
+
135
+ @public
136
+ """
137
+
138
+ model_config = {"frozen": True}
139
+
140
+ data: bytes = Field(repr=False)
141
+ width: int
142
+ height: int
143
+ index: int = Field(ge=0, description="0-indexed position")
144
+ total: int = Field(ge=1, description="Total number of parts")
145
+ source_y: int = Field(ge=0, description="Y offset in original image")
146
+ source_height: int = Field(ge=1, description="Height of region in original")
147
+
148
+ @property
149
+ def label(self) -> str:
150
+ """Human-readable label for LLM context, 1-indexed.
151
+
152
+ @public
153
+ """
154
+ if self.total == 1:
155
+ return "Full image"
156
+ return f"Part {self.index + 1}/{self.total}"
157
+
158
+
159
+ class ProcessedImage(BaseModel):
160
+ """Result of image processing.
161
+
162
+ @public
163
+
164
+ Iterable: ``for part in result`` iterates over parts.
165
+ """
166
+
167
+ model_config = {"frozen": True}
168
+
169
+ parts: list[ImagePart]
170
+ original_width: int
171
+ original_height: int
172
+ original_bytes: int
173
+ output_bytes: int
174
+ was_trimmed: bool = Field(description="True if width was trimmed to fit")
175
+ warnings: list[str] = Field(default_factory=list)
176
+
177
+ @property
178
+ def compression_ratio(self) -> float:
179
+ """Output size / input size (lower means more compression).
180
+
181
+ @public
182
+ """
183
+ if self.original_bytes <= 0:
184
+ return 1.0
185
+ return self.output_bytes / self.original_bytes
186
+
187
+ def __len__(self) -> int:
188
+ return len(self.parts)
189
+
190
+ def __iter__(self): # type: ignore[override]
191
+ return iter(self.parts)
192
+
193
+ def __getitem__(self, idx: int) -> ImagePart:
194
+ return self.parts[idx]
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Exceptions
199
+ # ---------------------------------------------------------------------------
200
+
201
+
202
+ class ImageProcessingError(Exception):
203
+ """Image processing failed.
204
+
205
+ @public
206
+ """
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Public API
211
+ # ---------------------------------------------------------------------------
212
+
213
+
214
+ def process_image(
215
+ image: bytes | Document,
216
+ preset: ImagePreset = ImagePreset.GEMINI,
217
+ config: ImageProcessingConfig | None = None,
218
+ ) -> ProcessedImage:
219
+ """Process an image for LLM vision models.
220
+
221
+ @public
222
+
223
+ Splits tall images vertically with overlap, trims width if needed, and
224
+ compresses to JPEG. The default preset is **GEMINI** (3 000 px, 9 M pixels).
225
+
226
+ Args:
227
+ image: Raw image bytes or a Document whose content is an image.
228
+ preset: Model preset (ignored when *config* is provided).
229
+ config: Custom configuration that overrides the preset.
230
+
231
+ Returns:
232
+ A ``ProcessedImage`` containing one or more ``ImagePart`` objects.
233
+
234
+ Raises:
235
+ ImageProcessingError: If the image cannot be decoded or processed.
236
+
237
+ Example:
238
+ >>> result = process_image(screenshot_bytes)
239
+ >>> for part in result:
240
+ ... print(part.label, len(part.data))
241
+ """
242
+ effective = config if config is not None else ImageProcessingConfig.for_preset(preset)
243
+
244
+ # Resolve input bytes
245
+ raw: bytes
246
+ if isinstance(image, Document):
247
+ raw = image.content
248
+ elif isinstance(image, bytes): # type: ignore[reportUnnecessaryIsInstance]
249
+ raw = image
250
+ else:
251
+ raise ImageProcessingError(f"Unsupported image input type: {type(image)}")
252
+
253
+ if not raw:
254
+ raise ImageProcessingError("Empty image data")
255
+
256
+ original_bytes = len(raw)
257
+
258
+ # Load & normalise
259
+ try:
260
+ img = load_and_normalize(raw)
261
+ except Exception as exc:
262
+ raise ImageProcessingError(f"Failed to decode image: {exc}") from exc
263
+
264
+ original_width, original_height = img.size
265
+
266
+ # Plan
267
+ plan = plan_split(
268
+ width=original_width,
269
+ height=original_height,
270
+ max_dimension=effective.max_dimension,
271
+ max_pixels=effective.max_pixels,
272
+ overlap_fraction=effective.overlap_fraction,
273
+ max_parts=effective.max_parts,
274
+ )
275
+
276
+ # Execute
277
+ raw_parts = execute_split(img, plan, effective.jpeg_quality)
278
+
279
+ # Build result
280
+ parts: list[ImagePart] = []
281
+ total = len(raw_parts)
282
+ total_output = 0
283
+
284
+ for idx, (data, w, h, sy, sh) in enumerate(raw_parts):
285
+ total_output += len(data)
286
+ parts.append(
287
+ ImagePart(
288
+ data=data,
289
+ width=w,
290
+ height=h,
291
+ index=idx,
292
+ total=total,
293
+ source_y=sy,
294
+ source_height=sh,
295
+ )
296
+ )
297
+
298
+ return ProcessedImage(
299
+ parts=parts,
300
+ original_width=original_width,
301
+ original_height=original_height,
302
+ original_bytes=original_bytes,
303
+ output_bytes=total_output,
304
+ was_trimmed=plan.trim_width is not None,
305
+ warnings=plan.warnings,
306
+ )
307
+
308
+
309
+ def process_image_to_documents(
310
+ image: bytes | Document,
311
+ preset: ImagePreset = ImagePreset.GEMINI,
312
+ config: ImageProcessingConfig | None = None,
313
+ name_prefix: str = "image",
314
+ sources: list[str] | None = None,
315
+ ) -> list[TemporaryDocument]:
316
+ """Process an image and return parts as ``TemporaryDocument`` list.
317
+
318
+ @public
319
+
320
+ Convenience wrapper around ``process_image`` for direct integration
321
+ with ``AIMessages``.
322
+
323
+ Args:
324
+ image: Raw image bytes or a Document.
325
+ preset: Model preset (ignored when *config* is provided).
326
+ config: Custom configuration.
327
+ name_prefix: Prefix for generated document names.
328
+ sources: Optional provenance references attached to each document.
329
+
330
+ Returns:
331
+ List of ``TemporaryDocument`` instances with JPEG image data.
332
+
333
+ Example:
334
+ >>> docs = process_image_to_documents(screenshot_bytes)
335
+ >>> messages = AIMessages(docs)
336
+ """
337
+ result = process_image(image, preset=preset, config=config)
338
+
339
+ # Resolve sources
340
+ doc_sources: list[str] = list(sources or [])
341
+ if isinstance(image, Document):
342
+ doc_sources.append(image.sha256)
343
+
344
+ documents: list[TemporaryDocument] = []
345
+ for part in result.parts:
346
+ if len(result.parts) == 1:
347
+ name = f"{name_prefix}.jpg"
348
+ desc = None
349
+ else:
350
+ name = f"{name_prefix}_{part.index + 1:02d}_of_{part.total:02d}.jpg"
351
+ desc = part.label
352
+
353
+ documents.append(
354
+ TemporaryDocument.create(
355
+ name=name,
356
+ content=part.data,
357
+ description=desc,
358
+ sources=doc_sources or None,
359
+ )
360
+ )
361
+
362
+ return documents