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.
- ai_pipeline_core/__init__.py +32 -5
- ai_pipeline_core/debug/__init__.py +26 -0
- ai_pipeline_core/debug/config.py +91 -0
- ai_pipeline_core/debug/content.py +705 -0
- ai_pipeline_core/debug/processor.py +99 -0
- ai_pipeline_core/debug/summary.py +236 -0
- ai_pipeline_core/debug/writer.py +913 -0
- ai_pipeline_core/deployment/__init__.py +46 -0
- ai_pipeline_core/deployment/base.py +681 -0
- ai_pipeline_core/deployment/contract.py +84 -0
- ai_pipeline_core/deployment/helpers.py +98 -0
- ai_pipeline_core/documents/flow_document.py +1 -1
- ai_pipeline_core/documents/task_document.py +1 -1
- ai_pipeline_core/documents/temporary_document.py +1 -1
- ai_pipeline_core/flow/config.py +13 -2
- ai_pipeline_core/flow/options.py +4 -4
- ai_pipeline_core/images/__init__.py +362 -0
- ai_pipeline_core/images/_processing.py +157 -0
- ai_pipeline_core/llm/ai_messages.py +25 -4
- ai_pipeline_core/llm/client.py +15 -19
- ai_pipeline_core/llm/model_response.py +5 -5
- ai_pipeline_core/llm/model_types.py +10 -13
- ai_pipeline_core/logging/logging_mixin.py +2 -2
- ai_pipeline_core/pipeline.py +1 -1
- ai_pipeline_core/progress.py +127 -0
- ai_pipeline_core/prompt_builder/__init__.py +5 -0
- ai_pipeline_core/prompt_builder/documents_prompt.jinja2 +23 -0
- ai_pipeline_core/prompt_builder/global_cache.py +78 -0
- ai_pipeline_core/prompt_builder/new_core_documents_prompt.jinja2 +6 -0
- ai_pipeline_core/prompt_builder/prompt_builder.py +253 -0
- ai_pipeline_core/prompt_builder/system_prompt.jinja2 +41 -0
- ai_pipeline_core/tracing.py +54 -2
- ai_pipeline_core/utils/deploy.py +214 -6
- ai_pipeline_core/utils/remote_deployment.py +37 -187
- {ai_pipeline_core-0.2.9.dist-info → ai_pipeline_core-0.3.3.dist-info}/METADATA +96 -27
- ai_pipeline_core-0.3.3.dist-info/RECORD +57 -0
- {ai_pipeline_core-0.2.9.dist-info → ai_pipeline_core-0.3.3.dist-info}/WHEEL +1 -1
- 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-0.2.9.dist-info/RECORD +0 -41
- {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
|
|
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
|
|
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
|
|
26
|
+
- Ignored by deployment save operations
|
|
27
27
|
- Useful for tests and debugging
|
|
28
28
|
|
|
29
29
|
Creating TemporaryDocuments:
|
ai_pipeline_core/flow/config.py
CHANGED
|
@@ -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
|
|
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.
|
ai_pipeline_core/flow/options.py
CHANGED
|
@@ -41,7 +41,7 @@ class FlowOptions(BaseSettings):
|
|
|
41
41
|
|
|
42
42
|
>>> # Or create programmatically:
|
|
43
43
|
>>> options = MyFlowOptions(
|
|
44
|
-
... core_model="gemini-
|
|
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
|
|
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-
|
|
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
|