ai-pipeline-core 0.2.0__py3-none-any.whl → 0.2.2__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 +1 -1
- ai_pipeline_core/documents/document.py +109 -1
- ai_pipeline_core/documents/document_list.py +78 -1
- ai_pipeline_core/llm/__init__.py +0 -5
- ai_pipeline_core/llm/ai_messages.py +125 -2
- ai_pipeline_core/llm/client.py +6 -10
- ai_pipeline_core/llm/model_options.py +13 -5
- ai_pipeline_core/llm/model_response.py +2 -1
- ai_pipeline_core/llm/model_types.py +3 -3
- ai_pipeline_core/pipeline.py +9 -0
- ai_pipeline_core/prompt_manager.py +8 -0
- ai_pipeline_core/tracing.py +231 -4
- {ai_pipeline_core-0.2.0.dist-info → ai_pipeline_core-0.2.2.dist-info}/METADATA +18 -5
- {ai_pipeline_core-0.2.0.dist-info → ai_pipeline_core-0.2.2.dist-info}/RECORD +16 -16
- {ai_pipeline_core-0.2.0.dist-info → ai_pipeline_core-0.2.2.dist-info}/WHEEL +0 -0
- {ai_pipeline_core-0.2.0.dist-info → ai_pipeline_core-0.2.2.dist-info}/licenses/LICENSE +0 -0
ai_pipeline_core/__init__.py
CHANGED
|
@@ -51,6 +51,7 @@ from .mime_type import (
|
|
|
51
51
|
)
|
|
52
52
|
|
|
53
53
|
TModel = TypeVar("TModel", bound=BaseModel)
|
|
54
|
+
TDocument = TypeVar("TDocument", bound="Document")
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
class Document(BaseModel, ABC):
|
|
@@ -97,6 +98,8 @@ class Document(BaseModel, ABC):
|
|
|
97
98
|
- Support for text, JSON, YAML, PDF, and image formats
|
|
98
99
|
- Conversion utilities between different formats
|
|
99
100
|
- Source provenance tracking via sources field
|
|
101
|
+
- Document type conversion via model_convert() method
|
|
102
|
+
- Standard Pydantic model_copy() for same-type copying
|
|
100
103
|
|
|
101
104
|
Class Variables:
|
|
102
105
|
MAX_CONTENT_SIZE: Maximum allowed content size in bytes (default 25MB)
|
|
@@ -223,6 +226,14 @@ class Document(BaseModel, ABC):
|
|
|
223
226
|
... sources=[source_doc.sha256] # Reference source document
|
|
224
227
|
... )
|
|
225
228
|
>>> processed.has_source(source_doc) # True
|
|
229
|
+
>>>
|
|
230
|
+
>>> # Document copying and type conversion:
|
|
231
|
+
>>> # Standard Pydantic model_copy (doesn't validate updates)
|
|
232
|
+
>>> copied = doc.model_copy(update={"name": "new_name.json"})
|
|
233
|
+
>>> # Type conversion with validation via model_convert
|
|
234
|
+
>>> task_doc = MyTaskDoc.create(name="temp.json", content={"data": "value"})
|
|
235
|
+
>>> flow_doc = task_doc.model_convert(MyFlowDoc) # Convert to FlowDocument
|
|
236
|
+
>>> flow_doc.is_flow # True
|
|
226
237
|
"""
|
|
227
238
|
|
|
228
239
|
MAX_CONTENT_SIZE: ClassVar[int] = 25 * 1024 * 1024
|
|
@@ -1498,6 +1509,8 @@ class Document(BaseModel, ABC):
|
|
|
1498
1509
|
- sha256: Full SHA256 hash in base32 encoding without padding (str)
|
|
1499
1510
|
- mime_type: Detected MIME type (str)
|
|
1500
1511
|
- sources: List of source strings (list[dict])
|
|
1512
|
+
- canonical_name: Canonical snake_case name for debug tracing (str)
|
|
1513
|
+
- class_name: Name of the actual document class for debug tracing (str)
|
|
1501
1514
|
- content: Encoded content (str)
|
|
1502
1515
|
- content_encoding: Either "utf-8" or "base64" (str)
|
|
1503
1516
|
|
|
@@ -1521,10 +1534,12 @@ class Document(BaseModel, ABC):
|
|
|
1521
1534
|
"sha256": self.sha256,
|
|
1522
1535
|
"mime_type": self.mime_type,
|
|
1523
1536
|
"sources": self.sources,
|
|
1537
|
+
"canonical_name": canonical_name_key(self.__class__),
|
|
1538
|
+
"class_name": self.__class__.__name__,
|
|
1524
1539
|
}
|
|
1525
1540
|
|
|
1526
1541
|
# Try to encode content as UTF-8, fall back to base64
|
|
1527
|
-
if self.is_text
|
|
1542
|
+
if self.is_text:
|
|
1528
1543
|
try:
|
|
1529
1544
|
result["content"] = self.content.decode("utf-8")
|
|
1530
1545
|
result["content_encoding"] = "utf-8"
|
|
@@ -1600,3 +1615,96 @@ class Document(BaseModel, ABC):
|
|
|
1600
1615
|
description=data.get("description"),
|
|
1601
1616
|
sources=data.get("sources", []),
|
|
1602
1617
|
)
|
|
1618
|
+
|
|
1619
|
+
@final
|
|
1620
|
+
def model_convert(
|
|
1621
|
+
self,
|
|
1622
|
+
new_type: type[TDocument],
|
|
1623
|
+
*,
|
|
1624
|
+
update: dict[str, Any] | None = None,
|
|
1625
|
+
deep: bool = False,
|
|
1626
|
+
) -> TDocument:
|
|
1627
|
+
"""Convert document to a different Document type with optional updates.
|
|
1628
|
+
|
|
1629
|
+
@public
|
|
1630
|
+
|
|
1631
|
+
Creates a new document of a different type, preserving all attributes
|
|
1632
|
+
while allowing updates. This is useful for converting between document
|
|
1633
|
+
types (e.g., TaskDocument to FlowDocument) while maintaining data integrity.
|
|
1634
|
+
|
|
1635
|
+
Args:
|
|
1636
|
+
new_type: Target Document class for conversion. Must be a concrete
|
|
1637
|
+
subclass of Document (not abstract classes like Document,
|
|
1638
|
+
FlowDocument, or TaskDocument).
|
|
1639
|
+
update: Dictionary of attributes to update. Supports any attributes
|
|
1640
|
+
that the Document constructor accepts (name, content,
|
|
1641
|
+
description, sources).
|
|
1642
|
+
deep: Whether to perform a deep copy of mutable attributes.
|
|
1643
|
+
|
|
1644
|
+
Returns:
|
|
1645
|
+
New Document instance of the specified type.
|
|
1646
|
+
|
|
1647
|
+
Raises:
|
|
1648
|
+
TypeError: If new_type is not a subclass of Document, is an abstract
|
|
1649
|
+
class, or if update contains invalid attributes.
|
|
1650
|
+
DocumentNameError: If the name violates the target type's FILES enum.
|
|
1651
|
+
DocumentSizeError: If content exceeds MAX_CONTENT_SIZE.
|
|
1652
|
+
|
|
1653
|
+
Example:
|
|
1654
|
+
>>> # Convert TaskDocument to FlowDocument
|
|
1655
|
+
>>> task_doc = MyTaskDoc.create(name="temp.json", content={"data": "value"})
|
|
1656
|
+
>>> flow_doc = task_doc.model_convert(MyFlowDoc)
|
|
1657
|
+
>>> assert flow_doc.is_flow
|
|
1658
|
+
>>> assert flow_doc.content == task_doc.content
|
|
1659
|
+
>>>
|
|
1660
|
+
>>> # Convert with updates
|
|
1661
|
+
>>> updated = task_doc.model_convert(
|
|
1662
|
+
... MyFlowDoc,
|
|
1663
|
+
... update={"name": "permanent.json", "description": "Converted"}
|
|
1664
|
+
... )
|
|
1665
|
+
>>>
|
|
1666
|
+
>>> # Track document lineage
|
|
1667
|
+
>>> derived = doc.model_convert(
|
|
1668
|
+
... ProcessedDoc,
|
|
1669
|
+
... update={"sources": [doc.sha256]}
|
|
1670
|
+
... )
|
|
1671
|
+
"""
|
|
1672
|
+
# Validate new_type
|
|
1673
|
+
try:
|
|
1674
|
+
# Use a runtime check to ensure it's a class
|
|
1675
|
+
if not isinstance(new_type, type): # type: ignore[reportIncompatibleArgumentType]
|
|
1676
|
+
raise TypeError(f"new_type must be a class, got {new_type}")
|
|
1677
|
+
if not issubclass(new_type, Document): # type: ignore[reportIncompatibleArgumentType]
|
|
1678
|
+
raise TypeError(f"new_type must be a subclass of Document, got {new_type}")
|
|
1679
|
+
except (TypeError, AttributeError):
|
|
1680
|
+
# Not a class at all
|
|
1681
|
+
raise TypeError(f"new_type must be a subclass of Document, got {new_type}")
|
|
1682
|
+
|
|
1683
|
+
# Check for abstract classes by name (avoid circular imports)
|
|
1684
|
+
class_name = new_type.__name__
|
|
1685
|
+
if class_name == "Document":
|
|
1686
|
+
raise TypeError("Cannot instantiate abstract Document class directly")
|
|
1687
|
+
if class_name == "FlowDocument":
|
|
1688
|
+
raise TypeError("Cannot instantiate abstract FlowDocument class directly")
|
|
1689
|
+
if class_name == "TaskDocument":
|
|
1690
|
+
raise TypeError("Cannot instantiate abstract TaskDocument class directly")
|
|
1691
|
+
|
|
1692
|
+
# Get current document data with proper typing
|
|
1693
|
+
data: dict[str, Any] = {
|
|
1694
|
+
"name": self.name,
|
|
1695
|
+
"content": self.content,
|
|
1696
|
+
"description": self.description,
|
|
1697
|
+
"sources": self.sources.copy() if deep else self.sources,
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
# Apply updates if provided
|
|
1701
|
+
if update:
|
|
1702
|
+
data.update(update)
|
|
1703
|
+
|
|
1704
|
+
# Create new document of target type
|
|
1705
|
+
return new_type(
|
|
1706
|
+
name=data["name"],
|
|
1707
|
+
content=data["content"],
|
|
1708
|
+
description=data.get("description"),
|
|
1709
|
+
sources=data.get("sources", []),
|
|
1710
|
+
)
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
@public
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
from
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from typing import Any, Callable, Iterable, SupportsIndex, Union, overload
|
|
7
8
|
|
|
8
9
|
from typing_extensions import Self
|
|
9
10
|
|
|
@@ -37,6 +38,7 @@ class DocumentList(list[Document]):
|
|
|
37
38
|
documents: list[Document] | None = None,
|
|
38
39
|
validate_same_type: bool = False,
|
|
39
40
|
validate_duplicates: bool = False,
|
|
41
|
+
frozen: bool = False,
|
|
40
42
|
) -> None:
|
|
41
43
|
"""Initialize DocumentList.
|
|
42
44
|
|
|
@@ -46,12 +48,15 @@ class DocumentList(list[Document]):
|
|
|
46
48
|
documents: Initial list of documents.
|
|
47
49
|
validate_same_type: Enforce same document type.
|
|
48
50
|
validate_duplicates: Prevent duplicate filenames.
|
|
51
|
+
frozen: If True, list is immutable from creation.
|
|
49
52
|
"""
|
|
50
53
|
super().__init__()
|
|
51
54
|
self._validate_same_type = validate_same_type
|
|
52
55
|
self._validate_duplicates = validate_duplicates
|
|
56
|
+
self._frozen = False # Initialize as unfrozen to allow initial population
|
|
53
57
|
if documents:
|
|
54
58
|
self.extend(documents)
|
|
59
|
+
self._frozen = frozen # Set frozen state after initial population
|
|
55
60
|
|
|
56
61
|
def _validate_no_duplicates(self) -> None:
|
|
57
62
|
"""Check for duplicate document names.
|
|
@@ -109,18 +114,51 @@ class DocumentList(list[Document]):
|
|
|
109
114
|
self._validate_no_description_files()
|
|
110
115
|
self._validate_types()
|
|
111
116
|
|
|
117
|
+
def freeze(self) -> None:
|
|
118
|
+
"""Permanently freeze the list, preventing modifications.
|
|
119
|
+
|
|
120
|
+
Once frozen, the list cannot be unfrozen.
|
|
121
|
+
"""
|
|
122
|
+
self._frozen = True
|
|
123
|
+
|
|
124
|
+
def copy(self) -> "DocumentList":
|
|
125
|
+
"""Create an unfrozen deep copy of the list.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
New unfrozen DocumentList with deep-copied documents.
|
|
129
|
+
"""
|
|
130
|
+
copied_docs = deepcopy(list(self))
|
|
131
|
+
return DocumentList(
|
|
132
|
+
documents=copied_docs,
|
|
133
|
+
validate_same_type=self._validate_same_type,
|
|
134
|
+
validate_duplicates=self._validate_duplicates,
|
|
135
|
+
frozen=False, # Copies are always unfrozen
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _check_frozen(self) -> None:
|
|
139
|
+
"""Check if list is frozen and raise if it is.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
RuntimeError: If the list is frozen.
|
|
143
|
+
"""
|
|
144
|
+
if self._frozen:
|
|
145
|
+
raise RuntimeError("Cannot modify frozen DocumentList")
|
|
146
|
+
|
|
112
147
|
def append(self, document: Document) -> None:
|
|
113
148
|
"""Add a document to the end of the list."""
|
|
149
|
+
self._check_frozen()
|
|
114
150
|
super().append(document)
|
|
115
151
|
self._validate()
|
|
116
152
|
|
|
117
153
|
def extend(self, documents: Iterable[Document]) -> None:
|
|
118
154
|
"""Add multiple documents to the list."""
|
|
155
|
+
self._check_frozen()
|
|
119
156
|
super().extend(documents)
|
|
120
157
|
self._validate()
|
|
121
158
|
|
|
122
159
|
def insert(self, index: SupportsIndex, document: Document) -> None:
|
|
123
160
|
"""Insert a document at the specified position."""
|
|
161
|
+
self._check_frozen()
|
|
124
162
|
super().insert(index, document)
|
|
125
163
|
self._validate()
|
|
126
164
|
|
|
@@ -132,6 +170,7 @@ class DocumentList(list[Document]):
|
|
|
132
170
|
|
|
133
171
|
def __setitem__(self, index: Union[SupportsIndex, slice], value: Any) -> None:
|
|
134
172
|
"""Set item or slice with validation."""
|
|
173
|
+
self._check_frozen()
|
|
135
174
|
super().__setitem__(index, value)
|
|
136
175
|
self._validate()
|
|
137
176
|
|
|
@@ -141,10 +180,48 @@ class DocumentList(list[Document]):
|
|
|
141
180
|
Returns:
|
|
142
181
|
Self: This DocumentList after modification.
|
|
143
182
|
"""
|
|
183
|
+
self._check_frozen()
|
|
144
184
|
result = super().__iadd__(other)
|
|
145
185
|
self._validate()
|
|
146
186
|
return result
|
|
147
187
|
|
|
188
|
+
def __delitem__(self, index: Union[SupportsIndex, slice]) -> None:
|
|
189
|
+
"""Delete item or slice from list."""
|
|
190
|
+
self._check_frozen()
|
|
191
|
+
super().__delitem__(index)
|
|
192
|
+
|
|
193
|
+
def pop(self, index: SupportsIndex = -1) -> Document:
|
|
194
|
+
"""Remove and return item at index.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Document removed from the list.
|
|
198
|
+
"""
|
|
199
|
+
self._check_frozen()
|
|
200
|
+
return super().pop(index)
|
|
201
|
+
|
|
202
|
+
def remove(self, document: Document) -> None:
|
|
203
|
+
"""Remove first occurrence of document."""
|
|
204
|
+
self._check_frozen()
|
|
205
|
+
super().remove(document)
|
|
206
|
+
|
|
207
|
+
def clear(self) -> None:
|
|
208
|
+
"""Remove all items from list."""
|
|
209
|
+
self._check_frozen()
|
|
210
|
+
super().clear()
|
|
211
|
+
|
|
212
|
+
def reverse(self) -> None:
|
|
213
|
+
"""Reverse list in place."""
|
|
214
|
+
self._check_frozen()
|
|
215
|
+
super().reverse()
|
|
216
|
+
|
|
217
|
+
def sort(self, *, key: Callable[[Document], Any] | None = None, reverse: bool = False) -> None:
|
|
218
|
+
"""Sort list in place."""
|
|
219
|
+
self._check_frozen()
|
|
220
|
+
if key is None:
|
|
221
|
+
super().sort(reverse=reverse) # type: ignore[call-arg]
|
|
222
|
+
else:
|
|
223
|
+
super().sort(key=key, reverse=reverse)
|
|
224
|
+
|
|
148
225
|
@overload
|
|
149
226
|
def filter_by(self, arg: str) -> "DocumentList": ...
|
|
150
227
|
|
ai_pipeline_core/llm/__init__.py
CHANGED
|
@@ -8,8 +8,6 @@ from .ai_messages import AIMessages, AIMessageType
|
|
|
8
8
|
from .client import (
|
|
9
9
|
generate,
|
|
10
10
|
generate_structured,
|
|
11
|
-
generate_with_retry_for_testing,
|
|
12
|
-
process_messages_for_testing,
|
|
13
11
|
)
|
|
14
12
|
from .model_options import ModelOptions
|
|
15
13
|
from .model_response import ModelResponse, StructuredModelResponse
|
|
@@ -24,7 +22,4 @@ __all__ = [
|
|
|
24
22
|
"StructuredModelResponse",
|
|
25
23
|
"generate",
|
|
26
24
|
"generate_structured",
|
|
27
|
-
# Internal functions exposed for testing only
|
|
28
|
-
"process_messages_for_testing",
|
|
29
|
-
"generate_with_retry_for_testing",
|
|
30
25
|
]
|
|
@@ -9,6 +9,8 @@ including text, documents, and model responses.
|
|
|
9
9
|
import base64
|
|
10
10
|
import hashlib
|
|
11
11
|
import json
|
|
12
|
+
from copy import deepcopy
|
|
13
|
+
from typing import Any, Callable, Iterable, SupportsIndex, Union
|
|
12
14
|
|
|
13
15
|
from openai.types.chat import (
|
|
14
16
|
ChatCompletionContentPartParam,
|
|
@@ -64,8 +66,8 @@ class AIMessages(list[AIMessageType]):
|
|
|
64
66
|
CAUTION: AIMessages is a list subclass. Always use list construction (e.g.,
|
|
65
67
|
`AIMessages(["text"])`) or empty constructor with append (e.g.,
|
|
66
68
|
`AIMessages(); messages.append("text")`). Never pass raw strings directly to the
|
|
67
|
-
constructor (`AIMessages("text")`) as this will
|
|
68
|
-
|
|
69
|
+
constructor (`AIMessages("text")`) as this will raise a TypeError to prevent
|
|
70
|
+
accidental character iteration.
|
|
69
71
|
|
|
70
72
|
Example:
|
|
71
73
|
>>> from ai_pipeline_core import llm
|
|
@@ -75,6 +77,127 @@ class AIMessages(list[AIMessageType]):
|
|
|
75
77
|
>>> messages.append(response) # Add the actual response
|
|
76
78
|
"""
|
|
77
79
|
|
|
80
|
+
def __init__(self, iterable: Iterable[AIMessageType] | None = None, *, frozen: bool = False):
|
|
81
|
+
"""Initialize AIMessages with optional iterable.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
iterable: Optional iterable of messages (list, tuple, etc.).
|
|
85
|
+
Must not be a string.
|
|
86
|
+
frozen: If True, list is immutable from creation.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
TypeError: If a string is passed directly to the constructor.
|
|
90
|
+
"""
|
|
91
|
+
if isinstance(iterable, str):
|
|
92
|
+
raise TypeError(
|
|
93
|
+
"AIMessages cannot be constructed from a string directly. "
|
|
94
|
+
"Use AIMessages(['text']) for a single message or "
|
|
95
|
+
"AIMessages() and then append('text')."
|
|
96
|
+
)
|
|
97
|
+
self._frozen = False # Initialize as unfrozen to allow initial population
|
|
98
|
+
if iterable is None:
|
|
99
|
+
super().__init__()
|
|
100
|
+
else:
|
|
101
|
+
super().__init__(iterable)
|
|
102
|
+
self._frozen = frozen # Set frozen state after initial population
|
|
103
|
+
|
|
104
|
+
def freeze(self) -> None:
|
|
105
|
+
"""Permanently freeze the list, preventing modifications.
|
|
106
|
+
|
|
107
|
+
Once frozen, the list cannot be unfrozen.
|
|
108
|
+
"""
|
|
109
|
+
self._frozen = True
|
|
110
|
+
|
|
111
|
+
def copy(self) -> "AIMessages":
|
|
112
|
+
"""Create an unfrozen deep copy of the list.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
New unfrozen AIMessages with deep-copied messages.
|
|
116
|
+
"""
|
|
117
|
+
copied_messages = deepcopy(list(self))
|
|
118
|
+
return AIMessages(copied_messages, frozen=False)
|
|
119
|
+
|
|
120
|
+
def _check_frozen(self) -> None:
|
|
121
|
+
"""Check if list is frozen and raise if it is.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
RuntimeError: If the list is frozen.
|
|
125
|
+
"""
|
|
126
|
+
if self._frozen:
|
|
127
|
+
raise RuntimeError("Cannot modify frozen AIMessages")
|
|
128
|
+
|
|
129
|
+
def append(self, message: AIMessageType) -> None:
|
|
130
|
+
"""Add a message to the end of the list."""
|
|
131
|
+
self._check_frozen()
|
|
132
|
+
super().append(message)
|
|
133
|
+
|
|
134
|
+
def extend(self, messages: Iterable[AIMessageType]) -> None:
|
|
135
|
+
"""Add multiple messages to the list."""
|
|
136
|
+
self._check_frozen()
|
|
137
|
+
super().extend(messages)
|
|
138
|
+
|
|
139
|
+
def insert(self, index: SupportsIndex, message: AIMessageType) -> None:
|
|
140
|
+
"""Insert a message at the specified position."""
|
|
141
|
+
self._check_frozen()
|
|
142
|
+
super().insert(index, message)
|
|
143
|
+
|
|
144
|
+
def __setitem__(
|
|
145
|
+
self,
|
|
146
|
+
index: Union[SupportsIndex, slice],
|
|
147
|
+
value: Union[AIMessageType, Iterable[AIMessageType]],
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Set item or slice."""
|
|
150
|
+
self._check_frozen()
|
|
151
|
+
super().__setitem__(index, value) # type: ignore[arg-type]
|
|
152
|
+
|
|
153
|
+
def __iadd__(self, other: Iterable[AIMessageType]) -> "AIMessages":
|
|
154
|
+
"""In-place addition (+=).
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
This AIMessages instance after modification.
|
|
158
|
+
"""
|
|
159
|
+
self._check_frozen()
|
|
160
|
+
return super().__iadd__(other)
|
|
161
|
+
|
|
162
|
+
def __delitem__(self, index: Union[SupportsIndex, slice]) -> None:
|
|
163
|
+
"""Delete item or slice from list."""
|
|
164
|
+
self._check_frozen()
|
|
165
|
+
super().__delitem__(index)
|
|
166
|
+
|
|
167
|
+
def pop(self, index: SupportsIndex = -1) -> AIMessageType:
|
|
168
|
+
"""Remove and return item at index.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
AIMessageType removed from the list.
|
|
172
|
+
"""
|
|
173
|
+
self._check_frozen()
|
|
174
|
+
return super().pop(index)
|
|
175
|
+
|
|
176
|
+
def remove(self, message: AIMessageType) -> None:
|
|
177
|
+
"""Remove first occurrence of message."""
|
|
178
|
+
self._check_frozen()
|
|
179
|
+
super().remove(message)
|
|
180
|
+
|
|
181
|
+
def clear(self) -> None:
|
|
182
|
+
"""Remove all items from list."""
|
|
183
|
+
self._check_frozen()
|
|
184
|
+
super().clear()
|
|
185
|
+
|
|
186
|
+
def reverse(self) -> None:
|
|
187
|
+
"""Reverse list in place."""
|
|
188
|
+
self._check_frozen()
|
|
189
|
+
super().reverse()
|
|
190
|
+
|
|
191
|
+
def sort(
|
|
192
|
+
self, *, key: Callable[[AIMessageType], Any] | None = None, reverse: bool = False
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Sort list in place."""
|
|
195
|
+
self._check_frozen()
|
|
196
|
+
if key is None:
|
|
197
|
+
super().sort(reverse=reverse) # type: ignore[call-arg]
|
|
198
|
+
else:
|
|
199
|
+
super().sort(key=key, reverse=reverse)
|
|
200
|
+
|
|
78
201
|
def get_last_message(self) -> AIMessageType:
|
|
79
202
|
"""Get the last message in the conversation.
|
|
80
203
|
|
ai_pipeline_core/llm/client.py
CHANGED
|
@@ -37,7 +37,7 @@ def _process_messages(
|
|
|
37
37
|
context: AIMessages,
|
|
38
38
|
messages: AIMessages,
|
|
39
39
|
system_prompt: str | None = None,
|
|
40
|
-
cache_ttl: str | None = "
|
|
40
|
+
cache_ttl: str | None = "5m",
|
|
41
41
|
) -> list[ChatCompletionMessageParam]:
|
|
42
42
|
"""Process and format messages for LLM API consumption.
|
|
43
43
|
|
|
@@ -245,7 +245,7 @@ async def generate(
|
|
|
245
245
|
model: Model to use (e.g., "gpt-5", "gemini-2.5-pro", "grok-4").
|
|
246
246
|
Accepts predefined models or any string for custom models.
|
|
247
247
|
context: Static context to cache (documents, examples, instructions).
|
|
248
|
-
Defaults to None (empty context). Cached for
|
|
248
|
+
Defaults to None (empty context). Cached for 5 minutes by default.
|
|
249
249
|
messages: Dynamic messages/queries. AIMessages or str ONLY.
|
|
250
250
|
Do not pass Document or DocumentList directly.
|
|
251
251
|
If string, converted to AIMessages internally.
|
|
@@ -338,13 +338,13 @@ async def generate(
|
|
|
338
338
|
- Context caching saves ~50-90% tokens on repeated calls
|
|
339
339
|
- First call: full token cost
|
|
340
340
|
- Subsequent calls (within cache TTL): only messages tokens
|
|
341
|
-
- Default cache TTL is
|
|
341
|
+
- Default cache TTL is 5m (production-optimized)
|
|
342
342
|
- Default retry logic: 3 attempts with 10s delay (production-optimized)
|
|
343
343
|
|
|
344
344
|
Caching:
|
|
345
345
|
When enabled in your LiteLLM proxy and supported by the upstream provider,
|
|
346
346
|
context messages may be cached to reduce token usage on repeated calls.
|
|
347
|
-
Default TTL is
|
|
347
|
+
Default TTL is 5m (optimized for production workloads). Configure caching
|
|
348
348
|
behavior centrally via your LiteLLM proxy settings, not per API call.
|
|
349
349
|
Savings depend on provider and payload; treat this as an optimization, not a guarantee.
|
|
350
350
|
|
|
@@ -524,6 +524,8 @@ async def generate_structured(
|
|
|
524
524
|
if isinstance(messages, str):
|
|
525
525
|
messages = AIMessages([messages])
|
|
526
526
|
|
|
527
|
+
assert isinstance(messages, AIMessages)
|
|
528
|
+
|
|
527
529
|
# Call the internal generate function with structured output enabled
|
|
528
530
|
try:
|
|
529
531
|
response = await _generate_with_retry(model, context, messages, options)
|
|
@@ -555,9 +557,3 @@ async def generate_structured(
|
|
|
555
557
|
|
|
556
558
|
# Create a StructuredModelResponse with the parsed value
|
|
557
559
|
return StructuredModelResponse[T](chat_completion=response, parsed_value=parsed_value)
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
# Public aliases for testing internal functions
|
|
561
|
-
# These are exported to allow testing of implementation details
|
|
562
|
-
process_messages_for_testing = _process_messages
|
|
563
|
-
generate_with_retry_for_testing = _generate_with_retry
|
|
@@ -45,7 +45,7 @@ class ModelOptions(BaseModel):
|
|
|
45
45
|
|
|
46
46
|
timeout: Maximum seconds to wait for response (default: 300).
|
|
47
47
|
|
|
48
|
-
cache_ttl: Cache TTL for context messages (default: "
|
|
48
|
+
cache_ttl: Cache TTL for context messages (default: "5m").
|
|
49
49
|
String format like "60s", "5m", or None to disable caching.
|
|
50
50
|
Applied to the last context message for efficient token reuse.
|
|
51
51
|
|
|
@@ -109,7 +109,7 @@ class ModelOptions(BaseModel):
|
|
|
109
109
|
- search_context_size only works with search models
|
|
110
110
|
- reasoning_effort only works with models that support explicit reasoning
|
|
111
111
|
- response_format is set internally by generate_structured()
|
|
112
|
-
- cache_ttl accepts formats like "120s", "5m", "1h" or None to disable caching
|
|
112
|
+
- cache_ttl accepts formats like "120s", "5m" (default), "1h" or None to disable caching
|
|
113
113
|
"""
|
|
114
114
|
|
|
115
115
|
temperature: float | None = None
|
|
@@ -118,11 +118,13 @@ class ModelOptions(BaseModel):
|
|
|
118
118
|
reasoning_effort: Literal["low", "medium", "high"] | None = None
|
|
119
119
|
retries: int = 3
|
|
120
120
|
retry_delay_seconds: int = 10
|
|
121
|
-
timeout: int =
|
|
122
|
-
cache_ttl: str | None = "
|
|
121
|
+
timeout: int = 600
|
|
122
|
+
cache_ttl: str | None = "5m"
|
|
123
123
|
service_tier: Literal["auto", "default", "flex", "scale", "priority"] | None = None
|
|
124
124
|
max_completion_tokens: int | None = None
|
|
125
125
|
response_format: type[BaseModel] | None = None
|
|
126
|
+
verbosity: Literal["low", "medium", "high"] | None = None
|
|
127
|
+
usage_tracking: bool = True
|
|
126
128
|
|
|
127
129
|
def to_openai_completion_kwargs(self) -> dict[str, Any]:
|
|
128
130
|
"""Convert options to OpenAI API completion parameters.
|
|
@@ -159,7 +161,7 @@ class ModelOptions(BaseModel):
|
|
|
159
161
|
Note:
|
|
160
162
|
- system_prompt is handled separately in _process_messages()
|
|
161
163
|
- retries and retry_delay_seconds are used by retry logic
|
|
162
|
-
- extra_body
|
|
164
|
+
- extra_body always includes usage tracking for cost monitoring
|
|
163
165
|
"""
|
|
164
166
|
kwargs: dict[str, Any] = {
|
|
165
167
|
"timeout": self.timeout,
|
|
@@ -184,4 +186,10 @@ class ModelOptions(BaseModel):
|
|
|
184
186
|
if self.service_tier:
|
|
185
187
|
kwargs["service_tier"] = self.service_tier
|
|
186
188
|
|
|
189
|
+
if self.verbosity:
|
|
190
|
+
kwargs["verbosity"] = self.verbosity
|
|
191
|
+
|
|
192
|
+
if self.usage_tracking:
|
|
193
|
+
kwargs["extra_body"]["usage"] = {"include": True}
|
|
194
|
+
|
|
187
195
|
return kwargs
|
|
@@ -110,7 +110,8 @@ class ModelResponse(ChatCompletion):
|
|
|
110
110
|
>>> if "error" in response.content.lower():
|
|
111
111
|
... # Handle error case
|
|
112
112
|
"""
|
|
113
|
-
|
|
113
|
+
content = self.choices[0].message.content or ""
|
|
114
|
+
return content.split("</think>")[-1].strip()
|
|
114
115
|
|
|
115
116
|
def set_model_options(self, options: dict[str, Any]) -> None:
|
|
116
117
|
"""Store the model configuration used for generation.
|
|
@@ -21,12 +21,12 @@ ModelName: TypeAlias = (
|
|
|
21
21
|
# Small models
|
|
22
22
|
"gemini-2.5-flash",
|
|
23
23
|
"gpt-5-mini",
|
|
24
|
-
"grok-
|
|
24
|
+
"grok-4-fast",
|
|
25
25
|
# Search models
|
|
26
26
|
"gemini-2.5-flash-search",
|
|
27
27
|
"sonar-pro-search",
|
|
28
28
|
"gpt-4o-search",
|
|
29
|
-
"grok-
|
|
29
|
+
"grok-4-fast-search",
|
|
30
30
|
]
|
|
31
31
|
| str
|
|
32
32
|
)
|
|
@@ -47,7 +47,7 @@ Model categories:
|
|
|
47
47
|
High-capability models for complex tasks requiring deep reasoning,
|
|
48
48
|
nuanced understanding, or creative generation.
|
|
49
49
|
|
|
50
|
-
Small models (gemini-2.5-flash, gpt-5-mini, grok-
|
|
50
|
+
Small models (gemini-2.5-flash, gpt-5-mini, grok-4-fast):
|
|
51
51
|
Efficient models optimized for speed and cost, suitable for
|
|
52
52
|
simpler tasks or high-volume processing.
|
|
53
53
|
|
ai_pipeline_core/pipeline.py
CHANGED
|
@@ -222,6 +222,7 @@ def pipeline_task(
|
|
|
222
222
|
trace_input_formatter: Callable[..., str] | None = None,
|
|
223
223
|
trace_output_formatter: Callable[..., str] | None = None,
|
|
224
224
|
trace_cost: float | None = None,
|
|
225
|
+
trace_trim_documents: bool = True,
|
|
225
226
|
# prefect passthrough
|
|
226
227
|
name: str | None = None,
|
|
227
228
|
description: str | None = None,
|
|
@@ -262,6 +263,7 @@ def pipeline_task(
|
|
|
262
263
|
trace_input_formatter: Callable[..., str] | None = None,
|
|
263
264
|
trace_output_formatter: Callable[..., str] | None = None,
|
|
264
265
|
trace_cost: float | None = None,
|
|
266
|
+
trace_trim_documents: bool = True,
|
|
265
267
|
# prefect passthrough
|
|
266
268
|
name: str | None = None,
|
|
267
269
|
description: str | None = None,
|
|
@@ -318,6 +320,8 @@ def pipeline_task(
|
|
|
318
320
|
trace_cost: Optional cost value to track in metadata. When provided and > 0,
|
|
319
321
|
sets gen_ai.usage.output_cost, gen_ai.usage.cost, and cost metadata.
|
|
320
322
|
Also forces trace level to "always" if not already set.
|
|
323
|
+
trace_trim_documents: Trim document content in traces to first 100 chars (default True).
|
|
324
|
+
Reduces trace size with large documents.
|
|
321
325
|
|
|
322
326
|
Prefect task parameters:
|
|
323
327
|
name: Task name (defaults to function name).
|
|
@@ -424,6 +428,7 @@ def pipeline_task(
|
|
|
424
428
|
ignore_inputs=trace_ignore_inputs,
|
|
425
429
|
input_formatter=trace_input_formatter,
|
|
426
430
|
output_formatter=trace_output_formatter,
|
|
431
|
+
trim_documents=trace_trim_documents,
|
|
427
432
|
)(_wrapper)
|
|
428
433
|
|
|
429
434
|
return cast(
|
|
@@ -474,6 +479,7 @@ def pipeline_flow(
|
|
|
474
479
|
trace_input_formatter: Callable[..., str] | None = None,
|
|
475
480
|
trace_output_formatter: Callable[..., str] | None = None,
|
|
476
481
|
trace_cost: float | None = None,
|
|
482
|
+
trace_trim_documents: bool = True,
|
|
477
483
|
# prefect passthrough
|
|
478
484
|
name: str | None = None,
|
|
479
485
|
version: str | None = None,
|
|
@@ -536,6 +542,8 @@ def pipeline_flow(
|
|
|
536
542
|
trace_cost: Optional cost value to track in metadata. When provided and > 0,
|
|
537
543
|
sets gen_ai.usage.output_cost, gen_ai.usage.cost, and cost metadata.
|
|
538
544
|
Also forces trace level to "always" if not already set.
|
|
545
|
+
trace_trim_documents: Trim document content in traces to first 100 chars (default True).
|
|
546
|
+
Reduces trace size with large documents.
|
|
539
547
|
|
|
540
548
|
Prefect flow parameters:
|
|
541
549
|
name: Flow name (defaults to function name).
|
|
@@ -670,6 +678,7 @@ def pipeline_flow(
|
|
|
670
678
|
ignore_inputs=trace_ignore_inputs,
|
|
671
679
|
input_formatter=trace_input_formatter,
|
|
672
680
|
output_formatter=trace_output_formatter,
|
|
681
|
+
trim_documents=trace_trim_documents,
|
|
673
682
|
)(_wrapper)
|
|
674
683
|
|
|
675
684
|
# --- Publish a schema where `documents` accepts str (path) OR DocumentList ---
|
|
@@ -18,6 +18,8 @@ Key features:
|
|
|
18
18
|
- Jinja2 template rendering with context
|
|
19
19
|
- Smart path resolution (.jinja2/.jinja extension handling)
|
|
20
20
|
- Clear error messages for missing templates
|
|
21
|
+
- Built-in global variables:
|
|
22
|
+
- current_date: Current date in format "03 January 2025" (string)
|
|
21
23
|
|
|
22
24
|
Example:
|
|
23
25
|
>>> from ai_pipeline_core import PromptManager
|
|
@@ -45,6 +47,7 @@ Note:
|
|
|
45
47
|
The extension can be omitted when calling get().
|
|
46
48
|
"""
|
|
47
49
|
|
|
50
|
+
from datetime import datetime
|
|
48
51
|
from pathlib import Path
|
|
49
52
|
from typing import Any
|
|
50
53
|
|
|
@@ -103,6 +106,8 @@ class PromptManager:
|
|
|
103
106
|
{% if instructions %}
|
|
104
107
|
Instructions: {{ instructions }}
|
|
105
108
|
{% endif %}
|
|
109
|
+
|
|
110
|
+
Date: {{ current_date }} # Current date in format "03 January 2025"
|
|
106
111
|
```
|
|
107
112
|
|
|
108
113
|
Note:
|
|
@@ -214,6 +219,9 @@ class PromptManager:
|
|
|
214
219
|
autoescape=False, # Important for prompt engineering
|
|
215
220
|
)
|
|
216
221
|
|
|
222
|
+
# Add current_date as a global string (format: "03 January 2025")
|
|
223
|
+
self.env.globals["current_date"] = datetime.now().strftime("%d %B %Y") # type: ignore[assignment]
|
|
224
|
+
|
|
217
225
|
def get(self, prompt_path: str, **kwargs: Any) -> str:
|
|
218
226
|
"""Load and render a Jinja2 template with the given context.
|
|
219
227
|
|
ai_pipeline_core/tracing.py
CHANGED
|
@@ -9,6 +9,7 @@ This module centralizes:
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import inspect
|
|
12
|
+
import json
|
|
12
13
|
import os
|
|
13
14
|
from functools import wraps
|
|
14
15
|
from typing import Any, Callable, Literal, ParamSpec, TypeVar, cast, overload
|
|
@@ -16,6 +17,10 @@ from typing import Any, Callable, Literal, ParamSpec, TypeVar, cast, overload
|
|
|
16
17
|
from lmnr import Attributes, Instruments, Laminar, observe
|
|
17
18
|
from pydantic import BaseModel
|
|
18
19
|
|
|
20
|
+
# Import for document trimming - needed for isinstance checks
|
|
21
|
+
# These are lazy imports only used when trim_documents is enabled
|
|
22
|
+
from ai_pipeline_core.documents import Document, DocumentList
|
|
23
|
+
from ai_pipeline_core.llm import AIMessages, ModelResponse
|
|
19
24
|
from ai_pipeline_core.settings import settings
|
|
20
25
|
|
|
21
26
|
# ---------------------------------------------------------------------------
|
|
@@ -34,6 +39,145 @@ Values:
|
|
|
34
39
|
"""
|
|
35
40
|
|
|
36
41
|
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Serialization helpers
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
def _serialize_for_tracing(obj: Any) -> Any:
|
|
46
|
+
"""Convert objects to JSON-serializable format for tracing.
|
|
47
|
+
|
|
48
|
+
Handles Pydantic models, Documents, and other special types.
|
|
49
|
+
This is extracted for better testability.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
obj: Object to serialize
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
JSON-serializable representation of the object
|
|
56
|
+
"""
|
|
57
|
+
# Our Document types - handle first to ensure serialize_model is used
|
|
58
|
+
if isinstance(obj, Document):
|
|
59
|
+
return obj.serialize_model()
|
|
60
|
+
# DocumentList
|
|
61
|
+
if isinstance(obj, DocumentList):
|
|
62
|
+
return [doc.serialize_model() for doc in obj]
|
|
63
|
+
# AIMessages
|
|
64
|
+
if isinstance(obj, AIMessages):
|
|
65
|
+
result = []
|
|
66
|
+
for msg in obj:
|
|
67
|
+
if isinstance(msg, Document):
|
|
68
|
+
result.append(msg.serialize_model())
|
|
69
|
+
else:
|
|
70
|
+
result.append(msg)
|
|
71
|
+
return result
|
|
72
|
+
# ModelResponse (special Pydantic model) - use standard model_dump
|
|
73
|
+
if isinstance(obj, ModelResponse):
|
|
74
|
+
return obj.model_dump()
|
|
75
|
+
# Pydantic models - use custom serializer that respects Document.serialize_model()
|
|
76
|
+
if isinstance(obj, BaseModel):
|
|
77
|
+
# For Pydantic models, we need to handle Document fields specially
|
|
78
|
+
data = {}
|
|
79
|
+
for field_name, field_value in obj.__dict__.items():
|
|
80
|
+
if isinstance(field_value, Document):
|
|
81
|
+
# Use serialize_model for Documents to get base_type
|
|
82
|
+
data[field_name] = field_value.serialize_model()
|
|
83
|
+
elif isinstance(field_value, BaseModel):
|
|
84
|
+
# Recursively handle nested Pydantic models
|
|
85
|
+
data[field_name] = _serialize_for_tracing(field_value)
|
|
86
|
+
else:
|
|
87
|
+
# Let Pydantic handle other fields normally
|
|
88
|
+
data[field_name] = field_value
|
|
89
|
+
return data
|
|
90
|
+
# Fallback to string representation
|
|
91
|
+
try:
|
|
92
|
+
return str(obj)
|
|
93
|
+
except Exception:
|
|
94
|
+
return f"<{type(obj).__name__}>"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Document trimming utilities
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
def _trim_document_content(doc_dict: dict[str, Any]) -> dict[str, Any]:
|
|
101
|
+
"""Trim document content based on document type and content type.
|
|
102
|
+
|
|
103
|
+
For non-FlowDocuments:
|
|
104
|
+
- Text content: Keep first 100 and last 100 chars (unless < 250 total)
|
|
105
|
+
- Binary content: Remove content entirely
|
|
106
|
+
|
|
107
|
+
For FlowDocuments:
|
|
108
|
+
- Text content: Keep full content
|
|
109
|
+
- Binary content: Remove content entirely
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
doc_dict: Document dictionary with base_type, content, and content_encoding
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Modified document dictionary with trimmed content
|
|
116
|
+
"""
|
|
117
|
+
# Check if this looks like a document (has required fields)
|
|
118
|
+
if not isinstance(doc_dict, dict): # type: ignore[reportUnknownArgumentType]
|
|
119
|
+
return doc_dict
|
|
120
|
+
|
|
121
|
+
if "base_type" not in doc_dict or "content" not in doc_dict:
|
|
122
|
+
return doc_dict
|
|
123
|
+
|
|
124
|
+
base_type = doc_dict.get("base_type")
|
|
125
|
+
content = doc_dict.get("content", "")
|
|
126
|
+
content_encoding = doc_dict.get("content_encoding", "utf-8")
|
|
127
|
+
|
|
128
|
+
# For binary content (base64 encoded), remove content
|
|
129
|
+
if content_encoding == "base64":
|
|
130
|
+
doc_dict = doc_dict.copy()
|
|
131
|
+
doc_dict["content"] = "[binary content removed]"
|
|
132
|
+
return doc_dict
|
|
133
|
+
|
|
134
|
+
# For FlowDocuments with text content, keep full content
|
|
135
|
+
if base_type == "flow":
|
|
136
|
+
return doc_dict
|
|
137
|
+
|
|
138
|
+
# For other documents (task, temporary), trim text content
|
|
139
|
+
if isinstance(content, str) and len(content) > 250:
|
|
140
|
+
doc_dict = doc_dict.copy()
|
|
141
|
+
# Keep first 100 and last 100 characters
|
|
142
|
+
trimmed_chars = len(content) - 200 # Number of characters removed
|
|
143
|
+
doc_dict["content"] = (
|
|
144
|
+
content[:100] + f" ... [trimmed {trimmed_chars} chars] ... " + content[-100:]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return doc_dict
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _trim_documents_in_data(data: Any) -> Any:
|
|
151
|
+
"""Recursively trim document content in nested data structures.
|
|
152
|
+
|
|
153
|
+
Processes dictionaries, lists, and nested structures to find and trim
|
|
154
|
+
documents based on their type and content.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
data: Input data that may contain documents
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Data with document content trimmed according to rules
|
|
161
|
+
"""
|
|
162
|
+
if isinstance(data, dict):
|
|
163
|
+
# Check if this is a document
|
|
164
|
+
if "base_type" in data and "content" in data:
|
|
165
|
+
# This is a document, trim it
|
|
166
|
+
return _trim_document_content(data)
|
|
167
|
+
else:
|
|
168
|
+
# Recursively process dictionary values
|
|
169
|
+
return {k: _trim_documents_in_data(v) for k, v in data.items()}
|
|
170
|
+
elif isinstance(data, list):
|
|
171
|
+
# Process each item in list
|
|
172
|
+
return [_trim_documents_in_data(item) for item in data]
|
|
173
|
+
elif isinstance(data, tuple):
|
|
174
|
+
# Process tuples
|
|
175
|
+
return tuple(_trim_documents_in_data(item) for item in data)
|
|
176
|
+
else:
|
|
177
|
+
# Return other types unchanged
|
|
178
|
+
return data
|
|
179
|
+
|
|
180
|
+
|
|
37
181
|
# ---------------------------------------------------------------------------
|
|
38
182
|
# ``TraceInfo`` – metadata container
|
|
39
183
|
# ---------------------------------------------------------------------------
|
|
@@ -175,6 +319,7 @@ def trace(
|
|
|
175
319
|
output_formatter: Callable[..., str] | None = None,
|
|
176
320
|
ignore_exceptions: bool = False,
|
|
177
321
|
preserve_global_context: bool = True,
|
|
322
|
+
trim_documents: bool = True,
|
|
178
323
|
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
179
324
|
|
|
180
325
|
|
|
@@ -201,6 +346,7 @@ def trace(
|
|
|
201
346
|
output_formatter: Callable[..., str] | None = None,
|
|
202
347
|
ignore_exceptions: bool = False,
|
|
203
348
|
preserve_global_context: bool = True,
|
|
349
|
+
trim_documents: bool = True,
|
|
204
350
|
) -> Callable[[Callable[P, R]], Callable[P, R]] | Callable[P, R]:
|
|
205
351
|
"""Add Laminar observability tracing to any function.
|
|
206
352
|
|
|
@@ -257,6 +403,12 @@ def trace(
|
|
|
257
403
|
preserve_global_context: Maintain Laminar's global context across
|
|
258
404
|
calls (default True). Set False for isolated traces.
|
|
259
405
|
|
|
406
|
+
trim_documents: Automatically trim document content in traces (default True).
|
|
407
|
+
When enabled, non-FlowDocument text content is trimmed to
|
|
408
|
+
first/last 100 chars, and all binary content is removed.
|
|
409
|
+
FlowDocuments keep full text content but binary is removed.
|
|
410
|
+
Helps reduce trace size for large documents.
|
|
411
|
+
|
|
260
412
|
Returns:
|
|
261
413
|
Decorated function with same signature but added tracing.
|
|
262
414
|
|
|
@@ -363,6 +515,72 @@ def trace(
|
|
|
363
515
|
_output_formatter = output_formatter
|
|
364
516
|
_ignore_exceptions = ignore_exceptions
|
|
365
517
|
_preserve_global_context = preserve_global_context
|
|
518
|
+
_trim_documents = trim_documents
|
|
519
|
+
|
|
520
|
+
# Create document trimming formatters if needed
|
|
521
|
+
def _create_trimming_input_formatter(*args, **kwargs) -> str:
|
|
522
|
+
# First, let any custom formatter process the data
|
|
523
|
+
if _input_formatter:
|
|
524
|
+
result = _input_formatter(*args, **kwargs)
|
|
525
|
+
# If formatter returns string, try to parse and trim
|
|
526
|
+
if isinstance(result, str): # type: ignore[reportUnknownArgumentType]
|
|
527
|
+
try:
|
|
528
|
+
data = json.loads(result)
|
|
529
|
+
trimmed = _trim_documents_in_data(data)
|
|
530
|
+
return json.dumps(trimmed)
|
|
531
|
+
except (json.JSONDecodeError, TypeError):
|
|
532
|
+
return result
|
|
533
|
+
else:
|
|
534
|
+
# If formatter returns dict/list, trim it
|
|
535
|
+
trimmed = _trim_documents_in_data(result)
|
|
536
|
+
return json.dumps(trimmed) if not isinstance(trimmed, str) else trimmed
|
|
537
|
+
else:
|
|
538
|
+
# No custom formatter - mimic Laminar's get_input_from_func_args
|
|
539
|
+
# Build a dict with parameter names as keys (like Laminar does)
|
|
540
|
+
params = list(sig.parameters.keys())
|
|
541
|
+
data = {}
|
|
542
|
+
|
|
543
|
+
# Map args to parameter names
|
|
544
|
+
for i, arg in enumerate(args):
|
|
545
|
+
if i < len(params):
|
|
546
|
+
data[params[i]] = arg
|
|
547
|
+
|
|
548
|
+
# Add kwargs
|
|
549
|
+
data.update(kwargs)
|
|
550
|
+
|
|
551
|
+
# Serialize with our helper function
|
|
552
|
+
serialized = json.dumps(data, default=_serialize_for_tracing)
|
|
553
|
+
parsed = json.loads(serialized)
|
|
554
|
+
|
|
555
|
+
# Trim documents in the serialized data
|
|
556
|
+
trimmed = _trim_documents_in_data(parsed)
|
|
557
|
+
return json.dumps(trimmed)
|
|
558
|
+
|
|
559
|
+
def _create_trimming_output_formatter(result: Any) -> str:
|
|
560
|
+
# First, let any custom formatter process the data
|
|
561
|
+
if _output_formatter:
|
|
562
|
+
formatted = _output_formatter(result)
|
|
563
|
+
# If formatter returns string, try to parse and trim
|
|
564
|
+
if isinstance(formatted, str): # type: ignore[reportUnknownArgumentType]
|
|
565
|
+
try:
|
|
566
|
+
data = json.loads(formatted)
|
|
567
|
+
trimmed = _trim_documents_in_data(data)
|
|
568
|
+
return json.dumps(trimmed)
|
|
569
|
+
except (json.JSONDecodeError, TypeError):
|
|
570
|
+
return formatted
|
|
571
|
+
else:
|
|
572
|
+
# If formatter returns dict/list, trim it
|
|
573
|
+
trimmed = _trim_documents_in_data(formatted)
|
|
574
|
+
return json.dumps(trimmed) if not isinstance(trimmed, str) else trimmed
|
|
575
|
+
else:
|
|
576
|
+
# No custom formatter, serialize result with smart defaults
|
|
577
|
+
# Serialize with our extracted helper function
|
|
578
|
+
serialized = json.dumps(result, default=_serialize_for_tracing)
|
|
579
|
+
parsed = json.loads(serialized)
|
|
580
|
+
|
|
581
|
+
# Trim documents in the serialized data
|
|
582
|
+
trimmed = _trim_documents_in_data(parsed)
|
|
583
|
+
return json.dumps(trimmed)
|
|
366
584
|
|
|
367
585
|
# --- Helper function for runtime logic ---
|
|
368
586
|
def _prepare_and_get_observe_params(runtime_kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -401,10 +619,19 @@ def trace(
|
|
|
401
619
|
observe_params["ignore_output"] = _ignore_output
|
|
402
620
|
if _ignore_inputs is not None:
|
|
403
621
|
observe_params["ignore_inputs"] = _ignore_inputs
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
if
|
|
407
|
-
|
|
622
|
+
|
|
623
|
+
# Use trimming formatters if trim_documents is enabled
|
|
624
|
+
if _trim_documents:
|
|
625
|
+
# Use the trimming formatters (which may wrap custom formatters)
|
|
626
|
+
observe_params["input_formatter"] = _create_trimming_input_formatter
|
|
627
|
+
observe_params["output_formatter"] = _create_trimming_output_formatter
|
|
628
|
+
else:
|
|
629
|
+
# Use custom formatters directly if provided
|
|
630
|
+
if _input_formatter is not None:
|
|
631
|
+
observe_params["input_formatter"] = _input_formatter
|
|
632
|
+
if _output_formatter is not None:
|
|
633
|
+
observe_params["output_formatter"] = _output_formatter
|
|
634
|
+
|
|
408
635
|
if _ignore_exceptions:
|
|
409
636
|
observe_params["ignore_exceptions"] = _ignore_exceptions
|
|
410
637
|
if _preserve_global_context:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ai-pipeline-core
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Core utilities for AI-powered processing pipelines using prefect
|
|
5
5
|
Project-URL: Homepage, https://github.com/bbarwik/ai-pipeline-core
|
|
6
6
|
Project-URL: Repository, https://github.com/bbarwik/ai-pipeline-core
|
|
@@ -20,10 +20,10 @@ Classifier: Typing :: Typed
|
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Requires-Dist: httpx>=0.28.1
|
|
22
22
|
Requires-Dist: jinja2>=3.1.6
|
|
23
|
-
Requires-Dist: lmnr>=0.7.
|
|
24
|
-
Requires-Dist: openai>=1.
|
|
23
|
+
Requires-Dist: lmnr>=0.7.13
|
|
24
|
+
Requires-Dist: openai>=1.108.1
|
|
25
25
|
Requires-Dist: prefect-gcp[cloud-storage]>=0.6.10
|
|
26
|
-
Requires-Dist: prefect>=3.4.
|
|
26
|
+
Requires-Dist: prefect>=3.4.19
|
|
27
27
|
Requires-Dist: pydantic-settings>=2.10.1
|
|
28
28
|
Requires-Dist: pydantic>=2.11.7
|
|
29
29
|
Requires-Dist: python-magic>=0.4.27
|
|
@@ -224,9 +224,17 @@ if doc.is_text:
|
|
|
224
224
|
# Parse structured data
|
|
225
225
|
data = doc.as_json() # or as_yaml(), as_pydantic_model()
|
|
226
226
|
|
|
227
|
+
# Convert between document types (new in v0.2.1)
|
|
228
|
+
task_doc = flow_doc.model_convert(TaskDocument) # Convert FlowDocument to TaskDocument
|
|
229
|
+
new_doc = doc.model_convert(OtherDocType, content={"new": "data"}) # With content update
|
|
230
|
+
|
|
227
231
|
# Enhanced filtering (new in v0.1.14)
|
|
228
232
|
filtered = documents.filter_by([Doc1, Doc2, Doc3]) # Multiple types
|
|
229
233
|
named = documents.filter_by(["file1.txt", "file2.txt"]) # Multiple names
|
|
234
|
+
|
|
235
|
+
# Immutable collections (new in v0.2.1)
|
|
236
|
+
frozen_docs = DocumentList(docs, frozen=True) # Immutable document list
|
|
237
|
+
frozen_msgs = AIMessages(messages, frozen=True) # Immutable message list
|
|
230
238
|
```
|
|
231
239
|
|
|
232
240
|
### LLM Integration
|
|
@@ -312,13 +320,18 @@ async def process_chunk(data: str) -> str:
|
|
|
312
320
|
set_trace_cost(0.05) # Track costs (new in v0.1.14)
|
|
313
321
|
return result
|
|
314
322
|
|
|
315
|
-
@pipeline_flow(
|
|
323
|
+
@pipeline_flow(
|
|
324
|
+
config=MyFlowConfig,
|
|
325
|
+
trace_trim_documents=True # Trim large documents in traces (new in v0.2.1)
|
|
326
|
+
)
|
|
316
327
|
async def main_flow(
|
|
317
328
|
project_name: str,
|
|
318
329
|
documents: DocumentList,
|
|
319
330
|
flow_options: FlowOptions
|
|
320
331
|
) -> DocumentList:
|
|
321
332
|
# Your pipeline logic
|
|
333
|
+
# Large documents are automatically trimmed to 100 chars in traces
|
|
334
|
+
# for better observability without overwhelming the tracing UI
|
|
322
335
|
return DocumentList(results)
|
|
323
336
|
```
|
|
324
337
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
ai_pipeline_core/__init__.py,sha256=
|
|
1
|
+
ai_pipeline_core/__init__.py,sha256=LH0lGm02zWS9l7b3uzvvzOfSh7eDPok7RjVTP2_-Mv0,5720
|
|
2
2
|
ai_pipeline_core/exceptions.py,sha256=vx-XLTw2fJSPs-vwtXVYtqoQUcOc0JeI7UmHqRqQYWU,1569
|
|
3
|
-
ai_pipeline_core/pipeline.py,sha256=
|
|
3
|
+
ai_pipeline_core/pipeline.py,sha256=_00Qctqd7QibyXaetZv6KfyWoW9KZIRdndkYItNHWWI,28921
|
|
4
4
|
ai_pipeline_core/prefect.py,sha256=91ZgLJHsDsRUW77CpNmkKxYs3RCJuucPM3pjKmNBeDg,2199
|
|
5
|
-
ai_pipeline_core/prompt_manager.py,sha256=
|
|
5
|
+
ai_pipeline_core/prompt_manager.py,sha256=FAtb1yK7bGuAeuIJ523LOX9bd7TrcHG-TqZ7Lz4RJC0,12087
|
|
6
6
|
ai_pipeline_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
7
|
ai_pipeline_core/settings.py,sha256=-a9jVGg77xifj2SagCR9shXfzXUd-2MlrlquEu4htG8,5035
|
|
8
|
-
ai_pipeline_core/tracing.py,sha256=
|
|
8
|
+
ai_pipeline_core/tracing.py,sha256=9RaJaAX5Vp2C8t73TaY-a9gpVy6a_VtSY0JPohIoQsc,31460
|
|
9
9
|
ai_pipeline_core/documents/__init__.py,sha256=WHStvGZiSyybOcMTYxSV24U6MA3Am_0_Az5p-DuMFrk,738
|
|
10
|
-
ai_pipeline_core/documents/document.py,sha256=
|
|
11
|
-
ai_pipeline_core/documents/document_list.py,sha256=
|
|
10
|
+
ai_pipeline_core/documents/document.py,sha256=L3S_bfOiViMZLYRcmbV4-s3qO8HoGmqJ5g3bXNVs_3Q,67082
|
|
11
|
+
ai_pipeline_core/documents/document_list.py,sha256=Y_NCjfM_CjkIwHRD2iyGgYBuIykN8lT2IIH_uWOiGis,16254
|
|
12
12
|
ai_pipeline_core/documents/flow_document.py,sha256=g9wlRJRJgy4RsrrZ_P5Qu6kj0FuUFfhfUsRFgtq4NIM,3918
|
|
13
13
|
ai_pipeline_core/documents/mime_type.py,sha256=DkW88K95el5nAmhC00XLS0G3WpDXgs5IRsBWbKiqG3Y,7995
|
|
14
14
|
ai_pipeline_core/documents/task_document.py,sha256=40tFavBLX3FhK9-CRsuOH-3gUZ0zvEkqv9XcMFr8ySk,4077
|
|
@@ -17,12 +17,12 @@ ai_pipeline_core/documents/utils.py,sha256=ZyJNjFN7ihWno0K7dJZed7twYmmPLA0z40UzF
|
|
|
17
17
|
ai_pipeline_core/flow/__init__.py,sha256=2BfWYMOPYW5teGzwo-qzpn_bom1lxxry0bPsjVgcsCk,188
|
|
18
18
|
ai_pipeline_core/flow/config.py,sha256=3PCDph2n8dj-txqAvd9Wflbi_6lmfXFR9rUhM-szGSQ,18887
|
|
19
19
|
ai_pipeline_core/flow/options.py,sha256=2rKR2GifhXcyw8avI_oiEDMLC2jm5Qzpw8z56pbxUMo,2285
|
|
20
|
-
ai_pipeline_core/llm/__init__.py,sha256=
|
|
21
|
-
ai_pipeline_core/llm/ai_messages.py,sha256=
|
|
22
|
-
ai_pipeline_core/llm/client.py,sha256=
|
|
23
|
-
ai_pipeline_core/llm/model_options.py,sha256=
|
|
24
|
-
ai_pipeline_core/llm/model_response.py,sha256=
|
|
25
|
-
ai_pipeline_core/llm/model_types.py,sha256=
|
|
20
|
+
ai_pipeline_core/llm/__init__.py,sha256=3B_vtEzxrzidP1qOUNQ4RxlUmxZ2MBKQcUhQiTybM9g,661
|
|
21
|
+
ai_pipeline_core/llm/ai_messages.py,sha256=ML4rSCCEEu9_83Mnfn7r4yx0pUkarvnBsrxRZbO4ulw,13126
|
|
22
|
+
ai_pipeline_core/llm/client.py,sha256=oByE8whI1lvyqYUh6q3tKgXJhDiWiJWGztlfoZswrFE,22776
|
|
23
|
+
ai_pipeline_core/llm/model_options.py,sha256=7J9qt7P1qCnSP_NrBzPwx_P-HwkXDYFxKcYzriIJ3U4,7972
|
|
24
|
+
ai_pipeline_core/llm/model_response.py,sha256=iNSKobR3gzZ-CSC8hz8-grgL7jdd2IcnCSX0exdlg7o,15345
|
|
25
|
+
ai_pipeline_core/llm/model_types.py,sha256=2J4Qsb1x21I4eo_VPeaMMOW8shOGPqzJuoGjTLcBFPM,2791
|
|
26
26
|
ai_pipeline_core/logging/__init__.py,sha256=Nz6-ghAoENsgNmLD2ma9TW9M0U2_QfxuQ5DDW6Vt6M0,651
|
|
27
27
|
ai_pipeline_core/logging/logging.yml,sha256=YTW48keO_K5bkkb-KXGM7ZuaYKiquLsjsURei8Ql0V4,1353
|
|
28
28
|
ai_pipeline_core/logging/logging_config.py,sha256=pV2x6GgMPXrzPH27sicCSXfw56beio4C2JKCJ3NsXrg,6207
|
|
@@ -32,7 +32,7 @@ ai_pipeline_core/simple_runner/cli.py,sha256=yVyuxLY2RZvdNwmwT5LCe-km2nQJzWTPI0v
|
|
|
32
32
|
ai_pipeline_core/simple_runner/simple_runner.py,sha256=f6cIodYkul-Apu1d63T6kR5DZpiaCWpphUcEPp5XjFo,9102
|
|
33
33
|
ai_pipeline_core/storage/__init__.py,sha256=tcIkjJ3zPBLCyetwiJDewBvS2sbRJrDlBh3gEsQm08E,184
|
|
34
34
|
ai_pipeline_core/storage/storage.py,sha256=ClMr419Y-eU2RuOjZYd51dC0stWQk28Vb56PvQaoUwc,20007
|
|
35
|
-
ai_pipeline_core-0.2.
|
|
36
|
-
ai_pipeline_core-0.2.
|
|
37
|
-
ai_pipeline_core-0.2.
|
|
38
|
-
ai_pipeline_core-0.2.
|
|
35
|
+
ai_pipeline_core-0.2.2.dist-info/METADATA,sha256=EbqjpaeIwuScRMLTKdfYdut57O8GMUZ-HWYcioQ9r1A,15159
|
|
36
|
+
ai_pipeline_core-0.2.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
37
|
+
ai_pipeline_core-0.2.2.dist-info/licenses/LICENSE,sha256=kKj8mfbdWwkyG3U6n7ztB3bAZlEwShTkAsvaY657i3I,1074
|
|
38
|
+
ai_pipeline_core-0.2.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|