lionagi 0.12.3__py3-none-any.whl → 0.12.4__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 (64) hide show
  1. lionagi/config.py +123 -0
  2. lionagi/libs/schema/load_pydantic_model_from_schema.py +259 -0
  3. lionagi/libs/token_transform/perplexity.py +2 -4
  4. lionagi/libs/token_transform/synthlang_/translate_to_synthlang.py +1 -1
  5. lionagi/operations/chat/chat.py +2 -2
  6. lionagi/operations/communicate/communicate.py +20 -5
  7. lionagi/operations/parse/parse.py +131 -43
  8. lionagi/protocols/generic/pile.py +18 -4
  9. lionagi/protocols/messages/assistant_response.py +20 -1
  10. lionagi/service/connections/__init__.py +15 -0
  11. lionagi/service/connections/api_calling.py +230 -0
  12. lionagi/service/connections/endpoint.py +410 -0
  13. lionagi/service/connections/endpoint_config.py +137 -0
  14. lionagi/service/connections/header_factory.py +56 -0
  15. lionagi/service/connections/match_endpoint.py +49 -0
  16. lionagi/service/connections/providers/__init__.py +3 -0
  17. lionagi/service/connections/providers/anthropic_.py +87 -0
  18. lionagi/service/connections/providers/exa_.py +33 -0
  19. lionagi/service/connections/providers/oai_.py +166 -0
  20. lionagi/service/connections/providers/ollama_.py +122 -0
  21. lionagi/service/connections/providers/perplexity_.py +29 -0
  22. lionagi/service/imodel.py +36 -144
  23. lionagi/service/manager.py +1 -7
  24. lionagi/service/{endpoints/rate_limited_processor.py → rate_limited_processor.py} +4 -2
  25. lionagi/service/resilience.py +545 -0
  26. lionagi/service/third_party/README.md +71 -0
  27. lionagi/service/third_party/__init__.py +0 -0
  28. lionagi/service/third_party/anthropic_models.py +159 -0
  29. lionagi/service/{providers/exa_/models.py → third_party/exa_models.py} +18 -13
  30. lionagi/service/third_party/openai_models.py +18241 -0
  31. lionagi/service/third_party/pplx_models.py +156 -0
  32. lionagi/service/types.py +5 -4
  33. lionagi/session/branch.py +12 -7
  34. lionagi/tools/file/reader.py +1 -1
  35. lionagi/tools/memory/tools.py +497 -0
  36. lionagi/version.py +1 -1
  37. {lionagi-0.12.3.dist-info → lionagi-0.12.4.dist-info}/METADATA +16 -17
  38. {lionagi-0.12.3.dist-info → lionagi-0.12.4.dist-info}/RECORD +42 -43
  39. lionagi/service/endpoints/__init__.py +0 -3
  40. lionagi/service/endpoints/base.py +0 -706
  41. lionagi/service/endpoints/chat_completion.py +0 -116
  42. lionagi/service/endpoints/match_endpoint.py +0 -72
  43. lionagi/service/providers/__init__.py +0 -3
  44. lionagi/service/providers/anthropic_/__init__.py +0 -3
  45. lionagi/service/providers/anthropic_/messages.py +0 -99
  46. lionagi/service/providers/exa_/search.py +0 -80
  47. lionagi/service/providers/exa_/types.py +0 -7
  48. lionagi/service/providers/groq_/__init__.py +0 -3
  49. lionagi/service/providers/groq_/chat_completions.py +0 -56
  50. lionagi/service/providers/ollama_/__init__.py +0 -3
  51. lionagi/service/providers/ollama_/chat_completions.py +0 -134
  52. lionagi/service/providers/openai_/__init__.py +0 -3
  53. lionagi/service/providers/openai_/chat_completions.py +0 -101
  54. lionagi/service/providers/openai_/spec.py +0 -14
  55. lionagi/service/providers/openrouter_/__init__.py +0 -3
  56. lionagi/service/providers/openrouter_/chat_completions.py +0 -62
  57. lionagi/service/providers/perplexity_/__init__.py +0 -3
  58. lionagi/service/providers/perplexity_/chat_completions.py +0 -44
  59. lionagi/service/providers/perplexity_/models.py +0 -144
  60. lionagi/service/providers/types.py +0 -17
  61. /lionagi/{service/providers/exa_/__init__.py → py.typed} +0 -0
  62. /lionagi/service/{endpoints/token_calculator.py → token_calculator.py} +0 -0
  63. {lionagi-0.12.3.dist-info → lionagi-0.12.4.dist-info}/WHEEL +0 -0
  64. {lionagi-0.12.3.dist-info → lionagi-0.12.4.dist-info}/licenses/LICENSE +0 -0
lionagi/config.py ADDED
@@ -0,0 +1,123 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from typing import Any, ClassVar
6
+
7
+ from pydantic import BaseModel, Field, SecretStr
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+
11
+ class CacheConfig(BaseModel):
12
+ """Configuration for aiocache."""
13
+
14
+ ttl: int = 300
15
+ key: str | None = None
16
+ namespace: str | None = None
17
+ key_builder: Any = None
18
+ skip_cache_func: Any = lambda _: False
19
+ serializer: dict[str, Any] | None = None
20
+ plugins: Any = None
21
+ alias: str | None = None
22
+ noself: Any = lambda _: False
23
+
24
+ def as_kwargs(self) -> dict[str, Any]:
25
+ """Convert config to kwargs dict for @cached decorator.
26
+
27
+ Removes unserialisable callables that aiocache can't pickle.
28
+ """
29
+ raw = self.model_dump(exclude_none=True)
30
+ # Remove all unserialisable callables
31
+ unserialisable_keys = (
32
+ "key_builder",
33
+ "skip_cache_func",
34
+ "noself",
35
+ "serializer",
36
+ "plugins",
37
+ )
38
+ for key in unserialisable_keys:
39
+ raw.pop(key, None)
40
+ return raw
41
+
42
+
43
+ class AppSettings(BaseSettings, frozen=True):
44
+ """Application settings with environment variable support."""
45
+
46
+ model_config = SettingsConfigDict(
47
+ env_file=(".env", ".env.local", ".secrets.env"),
48
+ env_file_encoding="utf-8",
49
+ case_sensitive=False,
50
+ extra="ignore",
51
+ )
52
+
53
+ aiocache_config: CacheConfig = Field(
54
+ default_factory=CacheConfig, description="Cache settings for aiocache"
55
+ )
56
+
57
+ # secrets
58
+ OPENAI_API_KEY: SecretStr | None = None
59
+ OPENROUTER_API_KEY: SecretStr | None = None
60
+ OLLAMA_API_KEY: SecretStr | None = None
61
+ EXA_API_KEY: SecretStr | None = None
62
+ PERPLEXITY_API_KEY: SecretStr | None = None
63
+ GROQ_API_KEY: SecretStr | None = None
64
+ ANTHROPIC_API_KEY: SecretStr | None = None
65
+
66
+ # defaults models
67
+ LIONAGI_EMBEDDING_PROVIDER: str = "openai"
68
+ LIONAGI_EMBEDDING_MODEL: str = "text-embedding-3-small"
69
+
70
+ LIONAGI_CHAT_PROVIDER: str = "openai"
71
+ LIONAGI_CHAT_MODEL: str = "gpt-4o"
72
+
73
+ # default storage
74
+ LIONAGI_AUTO_STORE_EVENT: bool = False
75
+ LIONAGI_STORAGE_PROVIDER: str = "async_qdrant"
76
+
77
+ LIONAGI_AUTO_EMBED_LOG: bool = False
78
+
79
+ # specific storage
80
+ LIONAGI_QDRANT_URL: str = "http://localhost:6333"
81
+ LIONAGI_DEFAULT_QDRANT_COLLECTION: str = "event_logs"
82
+
83
+ # Class variable to store the singleton instance
84
+ _instance: ClassVar[Any] = None
85
+
86
+ def get_secret(self, key_name: str) -> str:
87
+ """
88
+ Get the secret value for a given key name.
89
+
90
+ Args:
91
+ key_name: The name of the secret key (e.g., "OPENAI_API_KEY")
92
+
93
+ Returns:
94
+ The secret value as a string
95
+
96
+ Raises:
97
+ AttributeError: If the key doesn't exist
98
+ ValueError: If the key exists but is None
99
+ """
100
+ if not hasattr(self, key_name):
101
+ if "ollama" in key_name.lower():
102
+ return "ollama"
103
+ raise AttributeError(
104
+ f"Secret key '{key_name}' not found in settings"
105
+ )
106
+
107
+ secret = getattr(self, key_name)
108
+ if secret is None:
109
+ # Special case for Ollama - return "ollama" even if key exists but is None
110
+ if "ollama" in key_name.lower():
111
+ return "ollama"
112
+ raise ValueError(f"Secret key '{key_name}' is not set")
113
+
114
+ if isinstance(secret, SecretStr):
115
+ return secret.get_secret_value()
116
+
117
+ return str(secret)
118
+
119
+
120
+ # Create a singleton instance
121
+ settings = AppSettings()
122
+ # Store the instance in the class variable for singleton pattern
123
+ AppSettings._instance = settings
@@ -0,0 +1,259 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import importlib.util
6
+ import json
7
+ import string
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Any, TypeVar
11
+
12
+ from pydantic import BaseModel, PydanticUserError
13
+
14
+ from lionagi.utils import is_import_installed
15
+
16
+ _HAS_DATAMODEL_CODE_GENERATOR = is_import_installed("datamodel-code-generator")
17
+
18
+ # Import at module level for easier mocking in tests
19
+ if _HAS_DATAMODEL_CODE_GENERATOR:
20
+ from datamodel_code_generator import (
21
+ DataModelType,
22
+ InputFileType,
23
+ PythonVersion,
24
+ generate,
25
+ )
26
+ else:
27
+ # Create dummy objects for when package is not installed
28
+ DataModelType = None
29
+ InputFileType = None
30
+ PythonVersion = None
31
+ generate = None
32
+
33
+
34
+ B = TypeVar("B", bound=BaseModel)
35
+
36
+
37
+ def load_pydantic_model_from_schema(
38
+ schema: str | dict[str, Any],
39
+ model_name: str = "DynamicModel",
40
+ /,
41
+ pydantic_version=None,
42
+ python_version=None,
43
+ ) -> type[BaseModel]:
44
+ """
45
+ Generates a Pydantic model class dynamically from a JSON schema string or dict,
46
+ and ensures it's fully resolved using model_rebuild() with the correct namespace.
47
+
48
+ Args:
49
+ schema: The JSON schema as a string or a Python dictionary.
50
+ model_name: The desired base name for the generated Pydantic model.
51
+ If the schema has a 'title', that will likely be used.
52
+ pydantic_version: The Pydantic model type to generate.
53
+ python_version: The target Python version for generated code syntax.
54
+
55
+ Returns:
56
+ The dynamically created and resolved Pydantic BaseModel class.
57
+
58
+ Raises:
59
+ ValueError: If the schema is invalid.
60
+ FileNotFoundError: If the generated model file is not found.
61
+ AttributeError: If the expected model class cannot be found.
62
+ RuntimeError: For errors during generation, loading, or rebuilding.
63
+ Exception: For other potential errors.
64
+ """
65
+ if not _HAS_DATAMODEL_CODE_GENERATOR:
66
+ error_msg = "`datamodel-code-generator` is not installed. Please install with `pip install datamodel-code-generator`."
67
+ raise ImportError(error_msg)
68
+
69
+ if DataModelType is not None:
70
+ pydantic_version = (
71
+ pydantic_version or DataModelType.PydanticV2BaseModel
72
+ )
73
+ python_version = python_version or PythonVersion.PY_312
74
+ else:
75
+ # These won't be used since we'll raise ImportError above
76
+ pydantic_version = None
77
+ python_version = None
78
+
79
+ schema_input_data: str
80
+ schema_dict: dict[str, Any]
81
+ resolved_model_name = (
82
+ model_name # Keep track of the potentially updated name
83
+ )
84
+
85
+ # --- 1. Prepare Schema Input ---
86
+ if isinstance(schema, dict):
87
+ try:
88
+ model_name_from_title = schema.get("title")
89
+ if model_name_from_title and isinstance(
90
+ model_name_from_title, str
91
+ ):
92
+ valid_chars = string.ascii_letters + string.digits + "_"
93
+ sanitized_title = "".join(
94
+ c
95
+ for c in model_name_from_title.replace(" ", "")
96
+ if c in valid_chars
97
+ )
98
+ if sanitized_title and sanitized_title[0].isalpha():
99
+ resolved_model_name = (
100
+ sanitized_title # Update the name to use
101
+ )
102
+ schema_dict = schema
103
+ schema_input_data = json.dumps(schema)
104
+ except TypeError as e:
105
+ error_msg = "Invalid dictionary provided for schema"
106
+ raise ValueError(error_msg) from e
107
+ elif isinstance(schema, str):
108
+ try:
109
+ schema_dict = json.loads(schema)
110
+ model_name_from_title = schema_dict.get("title")
111
+ if model_name_from_title and isinstance(
112
+ model_name_from_title, str
113
+ ):
114
+ valid_chars = string.ascii_letters + string.digits + "_"
115
+ sanitized_title = "".join(
116
+ c
117
+ for c in model_name_from_title.replace(" ", "")
118
+ if c in valid_chars
119
+ )
120
+ if sanitized_title and sanitized_title[0].isalpha():
121
+ resolved_model_name = (
122
+ sanitized_title # Update the name to use
123
+ )
124
+ schema_input_data = schema
125
+ except json.JSONDecodeError as e:
126
+ error_msg = "Invalid JSON schema string provided"
127
+ raise ValueError(error_msg) from e
128
+ else:
129
+ error_msg = "Schema must be a JSON string or a dictionary."
130
+ raise TypeError(error_msg)
131
+
132
+ # --- 2. Generate Code to Temporary File ---
133
+ with tempfile.TemporaryDirectory() as temporary_directory_name:
134
+ temporary_directory = Path(temporary_directory_name)
135
+ # Use a predictable but unique-ish filename
136
+ output_file = (
137
+ temporary_directory
138
+ / f"{resolved_model_name.lower()}_model_{hash(schema_input_data)}.py"
139
+ )
140
+ module_name = output_file.stem # e.g., "userprofile_model_12345"
141
+
142
+ try:
143
+ generate(
144
+ schema_input_data,
145
+ input_file_type=InputFileType.JsonSchema,
146
+ input_filename="schema.json",
147
+ output=output_file,
148
+ output_model_type=pydantic_version,
149
+ target_python_version=python_version,
150
+ # Ensure necessary base models are imported in the generated code
151
+ base_class="pydantic.BaseModel",
152
+ )
153
+ except Exception as e:
154
+ # Optional: Print generated code on failure for debugging
155
+ # if output_file.exists():
156
+ # print(f"--- Generated Code (Error) ---\n{output_file.read_text()}\n--------------------------")
157
+ error_msg = "Failed to generate model code"
158
+ raise RuntimeError(error_msg) from e
159
+
160
+ if not output_file.exists():
161
+ error_msg = f"Generated model file was not created: {output_file}"
162
+ raise FileNotFoundError(error_msg)
163
+
164
+ def get_modules():
165
+ spec = importlib.util.spec_from_file_location(
166
+ module_name, str(output_file)
167
+ )
168
+
169
+ if spec is None or spec.loader is None:
170
+ error_msg = f"Could not create module spec for {output_file}"
171
+ raise ImportError(error_msg)
172
+
173
+ return spec, importlib.util.module_from_spec(spec)
174
+
175
+ # --- 3. Import the Generated Module Dynamically ---
176
+ try:
177
+ spec, generated_module = get_modules()
178
+ # Important: Make pydantic available within the executed module's globals
179
+ # if it's not explicitly imported by the generated code for some reason.
180
+ # Usually, datamodel-code-generator handles imports well.
181
+ # generated_module.__dict__['BaseModel'] = BaseModel
182
+ spec.loader.exec_module(generated_module)
183
+
184
+ except Exception as e:
185
+ # Optional: Print generated code on failure for debugging
186
+ # print(f"--- Generated Code (Import Error) ---\n{output_file.read_text()}\n--------------------------")
187
+ error_msg = f"Failed to load generated module ({output_file})"
188
+ raise RuntimeError(error_msg) from e
189
+
190
+ def validate_base_model_class(m):
191
+ if not isinstance(m, type) or not issubclass(m, BaseModel):
192
+ error_msg = f"Found attribute '{resolved_model_name}' is not a Pydantic BaseModel class."
193
+ raise TypeError(error_msg)
194
+
195
+ # --- 4. Find the Model Class ---
196
+ model_class: type[BaseModel]
197
+ try:
198
+ # Use the name potentially derived from the schema title
199
+ model_class = getattr(generated_module, resolved_model_name)
200
+ validate_base_model_class(model_class)
201
+
202
+ except AttributeError:
203
+ # Fallback attempt (less likely now with title extraction)
204
+ try:
205
+ model_class = generated_module.Model # Default fallback name
206
+ validate_base_model_class(model_class)
207
+ print(
208
+ f"Warning: Model name '{resolved_model_name}' not found, falling back to 'Model'."
209
+ )
210
+ except AttributeError as e:
211
+ # List available Pydantic models found in the module for debugging
212
+ available_attrs = [
213
+ attr
214
+ for attr in dir(generated_module)
215
+ if isinstance(getattr(generated_module, attr, None), type)
216
+ and issubclass(
217
+ getattr(generated_module, attr, object), BaseModel
218
+ ) # Check inheritance safely
219
+ and getattr(generated_module, attr, None)
220
+ is not BaseModel # Exclude BaseModel itself
221
+ ]
222
+ # Optional: Print generated code on failure for debugging
223
+ # print(f"--- Generated Code (AttributeError) ---\n{output_file.read_text()}\n--------------------------")
224
+ error_msg = (
225
+ f"Could not find expected model class '{resolved_model_name}' or fallback 'Model' "
226
+ f"in the generated module {output_file}. "
227
+ f"Found Pydantic models: {available_attrs}"
228
+ )
229
+ raise AttributeError(error_msg) from e
230
+ except TypeError as e:
231
+ error_msg = (
232
+ f"Error validating found model class '{resolved_model_name}'"
233
+ )
234
+ raise TypeError(error_msg) from e
235
+
236
+ # --- 5. Rebuild the Model (Providing Namespace) ---
237
+ try:
238
+ # Pass the generated module's dictionary as the namespace
239
+ # for resolving type hints like 'Status', 'ProfileDetails', etc.
240
+ model_class.model_rebuild(
241
+ _types_namespace=generated_module.__dict__,
242
+ force=True, # Force rebuild even if Pydantic thinks it's okay
243
+ )
244
+ except (
245
+ PydanticUserError,
246
+ NameError,
247
+ ) as e: # Catch NameError explicitly here
248
+ # Optional: Print generated code on failure for debugging
249
+ # print(f"--- Generated Code (Rebuild Error) ---\n{output_file.read_text()}\n--------------------------")
250
+ error_msg = f"Error during model_rebuild for {resolved_model_name}"
251
+ raise RuntimeError(error_msg) from e
252
+ except Exception as e:
253
+ # Optional: Print generated code on failure for debugging
254
+ # print(f"--- Generated Code (Rebuild Error) ---\n{output_file.read_text()}\n--------------------------")
255
+ error_msg = f"Unexpected error during model_rebuild for {resolved_model_name}"
256
+ raise RuntimeError(error_msg) from e
257
+
258
+ # --- 6. Return the Resolved Model Class ---
259
+ return model_class
@@ -7,7 +7,7 @@ from pydantic import BaseModel
7
7
 
8
8
  from lionagi.protocols.generic.event import EventStatus
9
9
  from lionagi.protocols.generic.log import Log
10
- from lionagi.service.endpoints.base import APICalling
10
+ from lionagi.service.connections.api_calling import APICalling
11
11
  from lionagi.service.imodel import iModel
12
12
  from lionagi.utils import alcall, lcall, to_dict, to_list
13
13
 
@@ -208,9 +208,7 @@ class LLMCompressor:
208
208
  Tokenize text. If no custom tokenizer, use the default from lionagi.
209
209
  """
210
210
  if not self.tokenizer:
211
- from lionagi.service.endpoints.token_calculator import (
212
- TokenCalculator,
213
- )
211
+ from lionagi.service.token_calculator import TokenCalculator
214
212
 
215
213
  return TokenCalculator.tokenize(
216
214
  text,
@@ -93,7 +93,7 @@ async def translate_to_synthlang(
93
93
  else:
94
94
  branch = Branch(system=final_prompt, chat_model=chat_model)
95
95
 
96
- from lionagi.service.endpoints.token_calculator import TokenCalculator
96
+ from lionagi.service.token_calculator import TokenCalculator
97
97
 
98
98
  calculator = TokenCalculator()
99
99
 
@@ -122,8 +122,8 @@ async def chat(
122
122
  _msgs.append(i)
123
123
  messages = _msgs
124
124
 
125
- imodel = imodel or branch.chat_model
126
- if branch.msgs.system and imodel.sequential_exchange:
125
+ # All endpoints now assume sequential exchange (system message embedded in first user message)
126
+ if branch.msgs.system:
127
127
  messages = [msg for msg in messages if msg.role != "system"]
128
128
  first_instruction = None
129
129
 
@@ -91,12 +91,27 @@ async def communicate(
91
91
  return res.response
92
92
 
93
93
  if response_format is not None:
94
- return await branch.parse(
95
- text=res.response,
96
- request_type=response_format,
97
- max_retries=num_parse_retries,
94
+ # Default to raising errors unless explicitly set in fuzzy_match_kwargs
95
+ parse_kwargs = {
96
+ "handle_validation": "raise", # Default to raising errors
98
97
  **(fuzzy_match_kwargs or {}),
99
- )
98
+ }
99
+
100
+ try:
101
+ return await branch.parse(
102
+ text=res.response,
103
+ request_type=response_format,
104
+ max_retries=num_parse_retries,
105
+ **parse_kwargs,
106
+ )
107
+ except ValueError as e:
108
+ # Re-raise with more context
109
+ logging.error(
110
+ f"Failed to parse response '{res.response}' into {response_format}: {e}"
111
+ )
112
+ raise ValueError(
113
+ f"Failed to parse model response into {response_format.__name__}: {e}"
114
+ ) from e
100
115
 
101
116
  if request_fields is not None:
102
117
  _d = fuzzy_validate_mapping(
@@ -35,35 +35,117 @@ async def parse(
35
35
  suppress_conversion_errors: bool = False,
36
36
  response_format=None,
37
37
  ):
38
- _should_try = True
39
- num_try = 0
40
- response_model = text
41
38
  if operative is not None:
42
39
  max_retries = operative.max_retries
43
- response_format = operative.request_type
40
+ response_format = operative.request_type or response_format
41
+ request_type = request_type or operative.request_type
44
42
 
45
- while (
46
- _should_try
47
- and num_try < max_retries
48
- and not isinstance(response_model, BaseModel)
49
- ):
50
- num_try += 1
51
- if num_try == max_retries:
52
- _should_try = False
53
- _, res = await branch.chat(
54
- instruction="reformat text into specified model",
55
- guidane="follow the required response format, using the model schema as a guide",
56
- context=[{"text_to_format": text}],
57
- response_format=response_format or request_type,
58
- sender=branch.user,
59
- recipient=branch.id,
60
- imodel=branch.parse_model,
61
- return_ins_res_message=True,
43
+ if not request_type and not response_format:
44
+ raise ValueError(
45
+ "Either request_type or response_format must be provided"
46
+ )
47
+
48
+ request_type = request_type or response_format
49
+
50
+ # First attempt: try to parse the text directly
51
+ import logging
52
+
53
+ initial_error = None
54
+ parsed_data = None # Initialize to avoid scoping issues
55
+
56
+ try:
57
+ # Try fuzzy validation first
58
+ parsed_data = fuzzy_validate_mapping(
59
+ text,
60
+ breakdown_pydantic_annotation(request_type),
61
+ similarity_algo=similarity_algo,
62
+ similarity_threshold=similarity_threshold,
63
+ fuzzy_match=fuzzy_match,
64
+ handle_unmatched=handle_unmatched,
65
+ fill_value=fill_value,
66
+ fill_mapping=fill_mapping,
67
+ strict=strict,
68
+ suppress_conversion_errors=False, # Don't suppress on first attempt
62
69
  )
70
+
71
+ logging.debug(f"Parsed data from fuzzy validation: {parsed_data}")
72
+
73
+ # Validate with pydantic
63
74
  if operative is not None:
64
- response_model = operative.update_response_model(res.response)
75
+ response_model = operative.update_response_model(parsed_data)
65
76
  else:
66
- response_model = fuzzy_validate_mapping(
77
+ response_model = request_type.model_validate(parsed_data)
78
+
79
+ # If successful, return immediately
80
+ if isinstance(response_model, BaseModel):
81
+ return response_model
82
+
83
+ except Exception as e:
84
+ initial_error = e
85
+ # Log the initial parsing error for debugging
86
+ logging.debug(
87
+ f"Initial parsing failed for text '{text[:100]}...': {e}"
88
+ )
89
+ logging.debug(
90
+ f"Parsed data was: {locals().get('parsed_data', 'not set')}"
91
+ )
92
+
93
+ # Only continue if we have retries left
94
+ if max_retries <= 0:
95
+ if handle_validation == "raise":
96
+ raise ValueError(f"Failed to parse response: {e}") from e
97
+ elif handle_validation == "return_none":
98
+ return None
99
+ else: # return_value
100
+ return text
101
+
102
+ # If direct parsing failed, try using the parse model
103
+ num_try = 0
104
+ last_error = initial_error
105
+
106
+ # Check if the parsed_data exists but just failed validation
107
+ # This might mean we have the right structure but wrong values
108
+ if parsed_data is not None and isinstance(parsed_data, dict):
109
+ logging.debug(
110
+ f"Have parsed_data dict, checking if it's close to valid..."
111
+ )
112
+ # If we got a dict with the right keys, maybe we just need to clean it up
113
+ expected_fields = set(request_type.model_fields.keys())
114
+ parsed_fields = set(parsed_data.keys())
115
+ if expected_fields == parsed_fields and all(
116
+ parsed_data.get(k) is not None for k in expected_fields
117
+ ):
118
+ # We have the right structure with non-None values, don't retry with parse model
119
+ logging.debug(
120
+ "Structure matches with valid values, returning original error"
121
+ )
122
+ if handle_validation == "raise":
123
+ raise ValueError(
124
+ f"Failed to parse response: {initial_error}"
125
+ ) from initial_error
126
+ elif handle_validation == "return_none":
127
+ return None
128
+ else:
129
+ return text
130
+
131
+ while num_try < max_retries:
132
+ num_try += 1
133
+
134
+ try:
135
+ logging.debug(f"Retry {num_try}: Using parse model to reformat")
136
+ _, res = await branch.chat(
137
+ instruction="reformat text into specified model",
138
+ guidance="follow the required response format, using the model schema as a guide",
139
+ context=[{"text_to_format": text}],
140
+ response_format=request_type,
141
+ sender=branch.user,
142
+ recipient=branch.id,
143
+ imodel=branch.parse_model,
144
+ return_ins_res_message=True,
145
+ )
146
+
147
+ # Try to parse the reformatted response
148
+ parsed_data = fuzzy_validate_mapping(
67
149
  res.response,
68
150
  breakdown_pydantic_annotation(request_type),
69
151
  similarity_algo=similarity_algo,
@@ -75,25 +157,31 @@ async def parse(
75
157
  strict=strict,
76
158
  suppress_conversion_errors=suppress_conversion_errors,
77
159
  )
78
- try:
79
- response_model = request_type.model_validate(response_model)
80
- except InterruptedError as e:
81
- raise e
82
- except Exception:
83
- if _should_try:
84
- continue
85
- else:
86
- break
87
-
88
- if not isinstance(response_model, BaseModel):
89
- match handle_validation:
90
- case "return_value":
160
+
161
+ if operative is not None:
162
+ response_model = operative.update_response_model(parsed_data)
163
+ else:
164
+ response_model = request_type.model_validate(parsed_data)
165
+
166
+ # If successful, return
167
+ if isinstance(response_model, BaseModel):
91
168
  return response_model
92
- case "return_none":
93
- return None
94
- case "raise":
95
- raise ValueError(
96
- "Failed to parse response into request format"
97
- )
98
169
 
99
- return response_model
170
+ except InterruptedError as e:
171
+ raise e
172
+ except Exception as e:
173
+ last_error = e
174
+ # Continue to next retry
175
+ continue
176
+
177
+ # All retries exhausted
178
+ match handle_validation:
179
+ case "return_value":
180
+ return text
181
+ case "return_none":
182
+ return None
183
+ case "raise":
184
+ error_msg = "Failed to parse response into request format"
185
+ if last_error:
186
+ error_msg += f": {last_error}"
187
+ raise ValueError(error_msg) from last_error
@@ -19,7 +19,7 @@ from pathlib import Path
19
19
  from typing import Any, ClassVar, Generic, TypeVar
20
20
 
21
21
  import pandas as pd
22
- from pydantic import Field, field_serializer
22
+ from pydantic import Field
23
23
  from pydantic.fields import FieldInfo
24
24
  from typing_extensions import Self, override
25
25
 
@@ -910,9 +910,23 @@ class Pile(Element, Collective[E], Generic[E]):
910
910
  self.progression.insert(index, item_order)
911
911
  self.collections.update(item_dict)
912
912
 
913
- @field_serializer("collections")
914
- def _(self, value: dict[str, T]):
915
- return [i.to_dict() for i in value.values()]
913
+ def to_dict(self) -> dict[str, Any]:
914
+ """Convert pile to dictionary, properly handling collections."""
915
+ # Get base dict from parent class
916
+ dict_ = super().to_dict()
917
+
918
+ # Manually serialize collections
919
+ collections_list = []
920
+ for item in self.collections.values():
921
+ if hasattr(item, "to_dict"):
922
+ collections_list.append(item.to_dict())
923
+ elif hasattr(item, "model_dump"):
924
+ collections_list.append(item.model_dump())
925
+ else:
926
+ collections_list.append(str(item))
927
+
928
+ dict_["collections"] = collections_list
929
+ return dict_
916
930
 
917
931
  class AsyncPileIterator:
918
932
  def __init__(self, pile: Pile):