ai-pipeline-core 0.1.12__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 (90) hide show
  1. ai_pipeline_core/__init__.py +83 -119
  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 +14 -15
  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 +349 -1062
  30. ai_pipeline_core/documents/mime_type.py +40 -85
  31. ai_pipeline_core/documents/utils.py +62 -7
  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 +5 -3
  36. ai_pipeline_core/llm/ai_messages.py +284 -73
  37. ai_pipeline_core/llm/client.py +462 -209
  38. ai_pipeline_core/llm/model_options.py +86 -53
  39. ai_pipeline_core/llm/model_response.py +187 -241
  40. ai_pipeline_core/llm/model_types.py +34 -54
  41. ai_pipeline_core/logging/__init__.py +2 -9
  42. ai_pipeline_core/logging/logging.yml +1 -1
  43. ai_pipeline_core/logging/logging_config.py +27 -43
  44. ai_pipeline_core/logging/logging_mixin.py +17 -51
  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/observability/tracing.py +640 -0
  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 +26 -105
  70. ai_pipeline_core/settings.py +41 -32
  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.1.12.dist-info → ai_pipeline_core-0.4.1.dist-info}/WHEEL +1 -1
  75. ai_pipeline_core/documents/document_list.py +0 -240
  76. ai_pipeline_core/documents/flow_document.py +0 -128
  77. ai_pipeline_core/documents/task_document.py +0 -133
  78. ai_pipeline_core/documents/temporary_document.py +0 -95
  79. ai_pipeline_core/flow/__init__.py +0 -9
  80. ai_pipeline_core/flow/config.py +0 -314
  81. ai_pipeline_core/flow/options.py +0 -75
  82. ai_pipeline_core/pipeline.py +0 -717
  83. ai_pipeline_core/prefect.py +0 -54
  84. ai_pipeline_core/simple_runner/__init__.py +0 -24
  85. ai_pipeline_core/simple_runner/cli.py +0 -255
  86. ai_pipeline_core/simple_runner/simple_runner.py +0 -385
  87. ai_pipeline_core/tracing.py +0 -475
  88. ai_pipeline_core-0.1.12.dist-info/METADATA +0 -450
  89. ai_pipeline_core-0.1.12.dist-info/RECORD +0 -36
  90. {ai_pipeline_core-0.1.12.dist-info → ai_pipeline_core-0.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,309 @@
1
+ """Image processing utilities for LLM vision models.
2
+
3
+ Splits large images, compresses to JPEG, and respects model-specific constraints.
4
+ Designed for website screenshots, document pages, and other visual content
5
+ sent to vision-capable LLMs.
6
+ """
7
+
8
+ from enum import StrEnum
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from ai_pipeline_core.documents import Document
13
+
14
+ from ._processing import execute_split, load_and_normalize, plan_split
15
+
16
+ __all__ = [
17
+ "ImageDocument",
18
+ "ImagePart",
19
+ "ImagePreset",
20
+ "ImageProcessingConfig",
21
+ "ImageProcessingError",
22
+ "ProcessedImage",
23
+ "process_image",
24
+ "process_image_to_documents",
25
+ ]
26
+
27
+
28
+ class ImageDocument(Document): # noqa: RUF067
29
+ """Concrete document for processed image parts."""
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Configuration
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ class ImagePreset(StrEnum): # noqa: RUF067
38
+ """Presets for LLM vision model constraints."""
39
+
40
+ GEMINI = "gemini"
41
+ CLAUDE = "claude"
42
+ GPT4V = "gpt4v"
43
+
44
+
45
+ class ImageProcessingConfig(BaseModel): # noqa: RUF067
46
+ """Configuration for image processing.
47
+
48
+ Use ``for_preset`` for standard configurations or construct directly for
49
+ custom constraints.
50
+
51
+ """
52
+
53
+ model_config = {"frozen": True}
54
+
55
+ max_dimension: int = Field(
56
+ default=3000,
57
+ ge=100,
58
+ le=8192,
59
+ description="Maximum width AND height in pixels",
60
+ )
61
+ max_pixels: int = Field(
62
+ default=9_000_000,
63
+ ge=10_000,
64
+ description="Maximum total pixels per output image part",
65
+ )
66
+ overlap_fraction: float = Field(
67
+ default=0.20,
68
+ ge=0.0,
69
+ le=0.5,
70
+ description="Overlap between adjacent vertical parts (0.0-0.5)",
71
+ )
72
+ max_parts: int = Field(
73
+ default=20,
74
+ ge=1,
75
+ le=100,
76
+ description="Maximum number of output image parts",
77
+ )
78
+ jpeg_quality: int = Field(
79
+ default=60,
80
+ ge=10,
81
+ le=95,
82
+ description="JPEG compression quality (10-95)",
83
+ )
84
+
85
+ @classmethod
86
+ def for_preset(cls, preset: ImagePreset) -> "ImageProcessingConfig":
87
+ """Create configuration from a model preset."""
88
+ return _PRESETS[preset]
89
+
90
+
91
+ _PRESETS: dict[ImagePreset, ImageProcessingConfig] = { # noqa: RUF067
92
+ ImagePreset.GEMINI: ImageProcessingConfig(
93
+ max_dimension=3000,
94
+ max_pixels=9_000_000,
95
+ jpeg_quality=75,
96
+ ),
97
+ ImagePreset.CLAUDE: ImageProcessingConfig(
98
+ max_dimension=1568,
99
+ max_pixels=1_150_000,
100
+ jpeg_quality=60,
101
+ ),
102
+ ImagePreset.GPT4V: ImageProcessingConfig(
103
+ max_dimension=2048,
104
+ max_pixels=4_000_000,
105
+ jpeg_quality=70,
106
+ ),
107
+ }
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Result models
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ class ImagePart(BaseModel): # noqa: RUF067
116
+ """A single processed image part."""
117
+
118
+ model_config = {"frozen": True}
119
+
120
+ data: bytes = Field(repr=False)
121
+ width: int
122
+ height: int
123
+ index: int = Field(ge=0, description="0-indexed position")
124
+ total: int = Field(ge=1, description="Total number of parts")
125
+ source_y: int = Field(ge=0, description="Y offset in original image")
126
+ source_height: int = Field(ge=1, description="Height of region in original")
127
+
128
+ @property
129
+ def label(self) -> str:
130
+ """Human-readable label for LLM context, 1-indexed."""
131
+ if self.total == 1:
132
+ return "Full image"
133
+ return f"Part {self.index + 1}/{self.total}"
134
+
135
+
136
+ class ProcessedImage(BaseModel): # noqa: RUF067
137
+ """Result of image processing.
138
+
139
+ Iterable: ``for part in result`` iterates over parts.
140
+ """
141
+
142
+ model_config = {"frozen": True}
143
+
144
+ parts: list[ImagePart]
145
+ original_width: int
146
+ original_height: int
147
+ original_bytes: int
148
+ output_bytes: int
149
+ was_trimmed: bool = Field(description="True if width was trimmed to fit")
150
+ warnings: list[str] = Field(default_factory=list)
151
+
152
+ @property
153
+ def compression_ratio(self) -> float:
154
+ """Output size / input size (lower means more compression)."""
155
+ if self.original_bytes <= 0:
156
+ return 1.0
157
+ return self.output_bytes / self.original_bytes
158
+
159
+ def __len__(self) -> int:
160
+ return len(self.parts)
161
+
162
+ def __iter__(self): # type: ignore[override]
163
+ return iter(self.parts)
164
+
165
+ def __getitem__(self, idx: int) -> ImagePart:
166
+ return self.parts[idx]
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Exceptions
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ class ImageProcessingError(Exception): # noqa: RUF067
175
+ """Image processing failed."""
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Public API
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def process_image( # noqa: RUF067
184
+ image: bytes | Document,
185
+ preset: ImagePreset = ImagePreset.GEMINI,
186
+ config: ImageProcessingConfig | None = None,
187
+ ) -> ProcessedImage:
188
+ """Process an image for LLM vision models.
189
+
190
+ Splits tall images vertically with overlap, trims width if needed, and
191
+ compresses to JPEG. The default preset is **GEMINI** (3 000 px, 9 M pixels).
192
+
193
+ Args:
194
+ image: Raw image bytes or a Document whose content is an image.
195
+ preset: Model preset (ignored when *config* is provided).
196
+ config: Custom configuration that overrides the preset.
197
+
198
+ Returns:
199
+ A ``ProcessedImage`` containing one or more ``ImagePart`` objects.
200
+
201
+ Raises:
202
+ ImageProcessingError: If the image cannot be decoded or processed.
203
+
204
+ """
205
+ effective = config if config is not None else ImageProcessingConfig.for_preset(preset)
206
+
207
+ # Resolve input bytes
208
+ raw: bytes
209
+ if isinstance(image, Document):
210
+ raw = image.content
211
+ elif isinstance(image, bytes): # type: ignore[reportUnnecessaryIsInstance]
212
+ raw = image
213
+ else:
214
+ raise ImageProcessingError(f"Unsupported image input type: {type(image)}") # pyright: ignore[reportUnreachable]
215
+
216
+ if not raw:
217
+ raise ImageProcessingError("Empty image data")
218
+
219
+ original_bytes = len(raw)
220
+
221
+ # Load & normalise
222
+ try:
223
+ img = load_and_normalize(raw)
224
+ except Exception as exc:
225
+ raise ImageProcessingError(f"Failed to decode image: {exc}") from exc
226
+
227
+ original_width, original_height = img.size
228
+
229
+ # Plan
230
+ plan = plan_split(
231
+ width=original_width,
232
+ height=original_height,
233
+ max_dimension=effective.max_dimension,
234
+ max_pixels=effective.max_pixels,
235
+ overlap_fraction=effective.overlap_fraction,
236
+ max_parts=effective.max_parts,
237
+ )
238
+
239
+ # Execute
240
+ raw_parts = execute_split(img, plan, effective.jpeg_quality)
241
+
242
+ # Build result
243
+ parts: list[ImagePart] = []
244
+ total = len(raw_parts)
245
+ total_output = 0
246
+
247
+ for idx, (data, w, h, sy, sh) in enumerate(raw_parts):
248
+ total_output += len(data)
249
+ parts.append(
250
+ ImagePart(
251
+ data=data,
252
+ width=w,
253
+ height=h,
254
+ index=idx,
255
+ total=total,
256
+ source_y=sy,
257
+ source_height=sh,
258
+ )
259
+ )
260
+
261
+ return ProcessedImage(
262
+ parts=parts,
263
+ original_width=original_width,
264
+ original_height=original_height,
265
+ original_bytes=original_bytes,
266
+ output_bytes=total_output,
267
+ was_trimmed=plan.trim_width is not None,
268
+ warnings=plan.warnings,
269
+ )
270
+
271
+
272
+ def process_image_to_documents( # noqa: RUF067
273
+ image: bytes | Document,
274
+ preset: ImagePreset = ImagePreset.GEMINI,
275
+ config: ImageProcessingConfig | None = None,
276
+ name_prefix: str = "image",
277
+ sources: tuple[str, ...] | None = None,
278
+ ) -> list[ImageDocument]:
279
+ """Process an image and return parts as ImageDocument list.
280
+
281
+ Convenience wrapper around ``process_image`` for direct integration
282
+ with ``AIMessages``.
283
+ """
284
+ result = process_image(image, preset=preset, config=config)
285
+
286
+ source_list: list[str] = list(sources or ())
287
+ if isinstance(image, Document):
288
+ source_list.append(image.sha256)
289
+ doc_sources = tuple(source_list) if source_list else None
290
+
291
+ documents: list[ImageDocument] = []
292
+ for part in result.parts:
293
+ if len(result.parts) == 1:
294
+ name = f"{name_prefix}.jpg"
295
+ desc = None
296
+ else:
297
+ name = f"{name_prefix}_{part.index + 1:02d}_of_{part.total:02d}.jpg"
298
+ desc = part.label
299
+
300
+ documents.append(
301
+ ImageDocument.create(
302
+ name=name,
303
+ content=part.data,
304
+ description=desc,
305
+ sources=doc_sources,
306
+ )
307
+ )
308
+
309
+ return documents
@@ -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,12 +11,13 @@ 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
22
  "ModelOptions",
21
23
  "ModelResponse",