evolution-sdk 0.8.0__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.
- evolution/__init__.py +102 -0
- evolution/adapters/__init__.py +28 -0
- evolution/adapters/base.py +21 -0
- evolution/adapters/crewai.py +86 -0
- evolution/adapters/direct.py +120 -0
- evolution/adapters/langchain.py +112 -0
- evolution/adapters/llamaindex.py +96 -0
- evolution/capture/__init__.py +22 -0
- evolution/capture/introspect.py +164 -0
- evolution/capture/recorder.py +109 -0
- evolution/capture/tracker.py +194 -0
- evolution/evaluators.py +268 -0
- evolution/exceptions.py +52 -0
- evolution/models/__init__.py +40 -0
- evolution/models/artifacts.py +248 -0
- evolution/models/evaluation.py +77 -0
- evolution/models/execution.py +77 -0
- evolution/models/manifest.py +210 -0
- evolution/repository.py +360 -0
- evolution/validator.py +55 -0
- evolution_sdk-0.8.0.dist-info/METADATA +240 -0
- evolution_sdk-0.8.0.dist-info/RECORD +25 -0
- evolution_sdk-0.8.0.dist-info/WHEEL +5 -0
- evolution_sdk-0.8.0.dist-info/licenses/LICENSE +21 -0
- evolution_sdk-0.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Introspection and duck-typing utilities for LLM responses and function signatures.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from evolution.models.artifacts import ModelConfigArtifact, PromptArtifact
|
|
11
|
+
from evolution.models.execution import TokenUsage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def extract_inputs_from_args(func: Callable, args: tuple, kwargs: dict) -> str:
|
|
15
|
+
"""Formats function arguments into a readable input representation."""
|
|
16
|
+
sig = inspect.signature(func)
|
|
17
|
+
try:
|
|
18
|
+
bound = sig.bind(*args, **kwargs)
|
|
19
|
+
bound.apply_defaults()
|
|
20
|
+
arg_dict = bound.arguments
|
|
21
|
+
except Exception:
|
|
22
|
+
arg_dict = {"args": args, "kwargs": kwargs}
|
|
23
|
+
|
|
24
|
+
# If only one string argument, return it directly
|
|
25
|
+
if len(arg_dict) == 1:
|
|
26
|
+
val = next(iter(arg_dict.values()))
|
|
27
|
+
if isinstance(val, str):
|
|
28
|
+
return val
|
|
29
|
+
|
|
30
|
+
# Otherwise format key-value pairs
|
|
31
|
+
items = []
|
|
32
|
+
for k, v in arg_dict.items():
|
|
33
|
+
if k in ("self", "cls"):
|
|
34
|
+
continue
|
|
35
|
+
items.append(f"{k}={v!r}")
|
|
36
|
+
return ", ".join(items) if items else "(no input arguments)"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_docstring_prompt(func: Callable, name: str | None = None) -> PromptArtifact | None:
|
|
40
|
+
"""Extracts function docstring as a system prompt artifact if present."""
|
|
41
|
+
doc = inspect.getdoc(func)
|
|
42
|
+
if doc and doc.strip():
|
|
43
|
+
art_name = name or f"{func.__name__}-prompt"
|
|
44
|
+
return PromptArtifact(
|
|
45
|
+
name=art_name,
|
|
46
|
+
role="system",
|
|
47
|
+
description=f"Extracted docstring prompt for {func.__name__}",
|
|
48
|
+
)
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_model_config_from_kwargs(kwargs: dict[str, Any], name: str | None = None) -> ModelConfigArtifact | None:
|
|
53
|
+
"""Detects model and generation parameters passed in kwargs."""
|
|
54
|
+
model = kwargs.get("model") or kwargs.get("model_name")
|
|
55
|
+
if not model or not isinstance(model, str):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
provider = kwargs.get("provider", "openai")
|
|
59
|
+
if provider not in ("openai", "anthropic", "google", "local", "mistral", "cohere", "aws_bedrock"):
|
|
60
|
+
provider = "openai"
|
|
61
|
+
|
|
62
|
+
temperature = kwargs.get("temperature")
|
|
63
|
+
if temperature is not None:
|
|
64
|
+
try:
|
|
65
|
+
temperature = float(temperature)
|
|
66
|
+
except (ValueError, TypeError):
|
|
67
|
+
temperature = 0.7
|
|
68
|
+
|
|
69
|
+
return ModelConfigArtifact(
|
|
70
|
+
name=name or f"model-{model}",
|
|
71
|
+
model=model,
|
|
72
|
+
provider=provider,
|
|
73
|
+
temperature=temperature,
|
|
74
|
+
max_tokens=kwargs.get("max_tokens"),
|
|
75
|
+
top_p=kwargs.get("top_p"),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def extract_llm_response(result: Any) -> tuple[str, TokenUsage, str | None]:
|
|
80
|
+
"""Duck-types standard LLM response objects (OpenAI, Anthropic, LangChain, or dicts)
|
|
81
|
+
to extract:
|
|
82
|
+
- output text (str)
|
|
83
|
+
- token usage (TokenUsage)
|
|
84
|
+
- model name (str | None)
|
|
85
|
+
"""
|
|
86
|
+
output_text = ""
|
|
87
|
+
tokens = TokenUsage()
|
|
88
|
+
model_name: str | None = None
|
|
89
|
+
|
|
90
|
+
if result is None:
|
|
91
|
+
return "", tokens, None
|
|
92
|
+
|
|
93
|
+
# If it's already a simple string
|
|
94
|
+
if isinstance(result, str):
|
|
95
|
+
return result, tokens, None
|
|
96
|
+
|
|
97
|
+
# Check for dict response
|
|
98
|
+
if isinstance(result, dict):
|
|
99
|
+
# 1. Output extraction
|
|
100
|
+
if "choices" in result and isinstance(result["choices"], list) and len(result["choices"]) > 0:
|
|
101
|
+
first = result["choices"][0]
|
|
102
|
+
if isinstance(first, dict):
|
|
103
|
+
msg = first.get("message", {})
|
|
104
|
+
output_text = msg.get("content", "") if isinstance(msg, dict) else str(first.get("text", ""))
|
|
105
|
+
elif "content" in result:
|
|
106
|
+
if isinstance(result["content"], list) and len(result["content"]) > 0:
|
|
107
|
+
first = result["content"][0]
|
|
108
|
+
output_text = first.get("text", "") if isinstance(first, dict) else str(first)
|
|
109
|
+
else:
|
|
110
|
+
output_text = str(result["content"])
|
|
111
|
+
elif "output" in result:
|
|
112
|
+
output_text = str(result["output"])
|
|
113
|
+
elif "text" in result:
|
|
114
|
+
output_text = str(result["text"])
|
|
115
|
+
|
|
116
|
+
# 2. Token usage extraction
|
|
117
|
+
usage = result.get("usage", {})
|
|
118
|
+
if isinstance(usage, dict):
|
|
119
|
+
tokens.prompt_tokens = usage.get("prompt_tokens") or usage.get("input_tokens") or 0
|
|
120
|
+
tokens.completion_tokens = usage.get("completion_tokens") or usage.get("output_tokens") or 0
|
|
121
|
+
tokens.total_tokens = usage.get("total_tokens") or (tokens.prompt_tokens + tokens.completion_tokens)
|
|
122
|
+
|
|
123
|
+
# 3. Model name
|
|
124
|
+
model_name = result.get("model")
|
|
125
|
+
|
|
126
|
+
if not output_text:
|
|
127
|
+
output_text = str(result)
|
|
128
|
+
return output_text, tokens, model_name
|
|
129
|
+
|
|
130
|
+
# Check for Object response (OpenAI ChatCompletion, Anthropic Message, etc.) via attributes
|
|
131
|
+
# 1. Output extraction
|
|
132
|
+
if hasattr(result, "choices") and result.choices:
|
|
133
|
+
first = result.choices[0]
|
|
134
|
+
if hasattr(first, "message") and hasattr(first.message, "content"):
|
|
135
|
+
output_text = str(first.message.content or "")
|
|
136
|
+
elif hasattr(first, "text"):
|
|
137
|
+
output_text = str(first.text or "")
|
|
138
|
+
elif hasattr(result, "content"):
|
|
139
|
+
c = result.content
|
|
140
|
+
if isinstance(c, list) and len(c) > 0:
|
|
141
|
+
first = c[0]
|
|
142
|
+
output_text = getattr(first, "text", str(first))
|
|
143
|
+
else:
|
|
144
|
+
output_text = str(c)
|
|
145
|
+
elif hasattr(result, "output"):
|
|
146
|
+
output_text = str(result.output)
|
|
147
|
+
elif hasattr(result, "text"):
|
|
148
|
+
output_text = str(result.text)
|
|
149
|
+
|
|
150
|
+
# 2. Token usage extraction
|
|
151
|
+
if hasattr(result, "usage") and result.usage:
|
|
152
|
+
u = result.usage
|
|
153
|
+
tokens.prompt_tokens = getattr(u, "prompt_tokens", getattr(u, "input_tokens", 0)) or 0
|
|
154
|
+
tokens.completion_tokens = getattr(u, "completion_tokens", getattr(u, "output_tokens", 0)) or 0
|
|
155
|
+
tokens.total_tokens = getattr(u, "total_tokens", tokens.prompt_tokens + tokens.completion_tokens) or (tokens.prompt_tokens + tokens.completion_tokens)
|
|
156
|
+
|
|
157
|
+
# 3. Model name
|
|
158
|
+
if hasattr(result, "model") and result.model:
|
|
159
|
+
model_name = str(result.model)
|
|
160
|
+
|
|
161
|
+
if not output_text:
|
|
162
|
+
output_text = str(result)
|
|
163
|
+
|
|
164
|
+
return output_text, tokens, model_name
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Execution recording context manager for tracing AI invocations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from evolution.capture.introspect import extract_llm_response
|
|
12
|
+
from evolution.models.execution import Execution, TokenUsage
|
|
13
|
+
from evolution.repository import Repository
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RecordContextManager:
|
|
17
|
+
"""Context manager for tracing and recording an AI execution run."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
repo: Repository | Path | str | None = None,
|
|
22
|
+
inputs: str = "",
|
|
23
|
+
commit_id: str | None = None,
|
|
24
|
+
metadata: dict[str, Any] | None = None,
|
|
25
|
+
):
|
|
26
|
+
if isinstance(repo, Repository):
|
|
27
|
+
self.repo = repo
|
|
28
|
+
elif repo is not None:
|
|
29
|
+
self.repo = Repository.open(repo)
|
|
30
|
+
else:
|
|
31
|
+
try:
|
|
32
|
+
self.repo = Repository.open(".")
|
|
33
|
+
except Exception:
|
|
34
|
+
self.repo = None
|
|
35
|
+
|
|
36
|
+
self.inputs = inputs
|
|
37
|
+
self.outputs = ""
|
|
38
|
+
self.commit_id = commit_id
|
|
39
|
+
self.metadata = metadata or {}
|
|
40
|
+
self.tokens = TokenUsage()
|
|
41
|
+
self.duration_ms: int = 0
|
|
42
|
+
self.execution: Execution | None = None
|
|
43
|
+
self._start_time: float = 0.0
|
|
44
|
+
|
|
45
|
+
def set_input(self, inputs: str) -> RecordContextManager:
|
|
46
|
+
"""Sets or updates the input query string."""
|
|
47
|
+
self.inputs = inputs
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def set_output(self, outputs: Any) -> RecordContextManager:
|
|
51
|
+
"""Sets or updates the output string, auto-extracting from LLM response objects if needed."""
|
|
52
|
+
text, tokens, _ = extract_llm_response(outputs)
|
|
53
|
+
self.outputs = text
|
|
54
|
+
if tokens.total_tokens > 0:
|
|
55
|
+
self.tokens = tokens
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def set_tokens(
|
|
59
|
+
self,
|
|
60
|
+
prompt_tokens: int = 0,
|
|
61
|
+
completion_tokens: int = 0,
|
|
62
|
+
total_tokens: int = 0,
|
|
63
|
+
) -> RecordContextManager:
|
|
64
|
+
"""Sets token consumption metrics."""
|
|
65
|
+
self.tokens = TokenUsage(
|
|
66
|
+
prompt_tokens=prompt_tokens,
|
|
67
|
+
completion_tokens=completion_tokens,
|
|
68
|
+
total_tokens=total_tokens or (prompt_tokens + completion_tokens),
|
|
69
|
+
)
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def set_metadata(self, key: str, value: Any) -> RecordContextManager:
|
|
73
|
+
"""Sets a metadata key-value pair."""
|
|
74
|
+
self.metadata[key] = value
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> RecordContextManager:
|
|
78
|
+
self._start_time = time.perf_counter()
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
82
|
+
elapsed = time.perf_counter() - self._start_time
|
|
83
|
+
self.duration_ms = max(1, int(elapsed * 1000))
|
|
84
|
+
|
|
85
|
+
if exc_type is not None and not self.outputs:
|
|
86
|
+
self.outputs = f"Error ({exc_type.__name__}): {exc_val}"
|
|
87
|
+
self.metadata["error"] = str(exc_val)
|
|
88
|
+
self.metadata["error_type"] = exc_type.__name__
|
|
89
|
+
|
|
90
|
+
if self.repo is not None:
|
|
91
|
+
self.execution = self.repo.record_execution(
|
|
92
|
+
inputs=self.inputs,
|
|
93
|
+
outputs=self.outputs,
|
|
94
|
+
duration_ms=self.duration_ms,
|
|
95
|
+
prompt_tokens=self.tokens.prompt_tokens,
|
|
96
|
+
completion_tokens=self.tokens.completion_tokens,
|
|
97
|
+
commit_id=self.commit_id,
|
|
98
|
+
metadata=self.metadata,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def record(
|
|
103
|
+
repo: Repository | Path | str | None = None,
|
|
104
|
+
inputs: str = "",
|
|
105
|
+
commit_id: str | None = None,
|
|
106
|
+
metadata: dict[str, Any] | None = None,
|
|
107
|
+
) -> RecordContextManager:
|
|
108
|
+
"""Convenience factory returning an execution recording context manager."""
|
|
109
|
+
return RecordContextManager(repo=repo, inputs=inputs, commit_id=commit_id, metadata=metadata)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decorator-based telemetry and intelligence tracking.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import functools
|
|
9
|
+
import inspect
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from evolution.capture.introspect import (
|
|
15
|
+
extract_docstring_prompt,
|
|
16
|
+
extract_inputs_from_args,
|
|
17
|
+
extract_llm_response,
|
|
18
|
+
extract_model_config_from_kwargs,
|
|
19
|
+
)
|
|
20
|
+
from evolution.models.artifacts import ModelConfigArtifact
|
|
21
|
+
from evolution.models.execution import Execution
|
|
22
|
+
from evolution.repository import Repository
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def track(
|
|
26
|
+
_func: Callable | None = None,
|
|
27
|
+
*,
|
|
28
|
+
name: str | None = None,
|
|
29
|
+
repo: Repository | Path | str | None = None,
|
|
30
|
+
model: str | None = None,
|
|
31
|
+
provider: str = "openai",
|
|
32
|
+
temperature: float | None = None,
|
|
33
|
+
auto_manifest: bool = True,
|
|
34
|
+
metadata: dict[str, Any] | None = None,
|
|
35
|
+
):
|
|
36
|
+
"""Decorator to automatically capture intelligence artifacts and record executions.
|
|
37
|
+
|
|
38
|
+
Can be used as `@track` or `@track(...)`.
|
|
39
|
+
"""
|
|
40
|
+
def decorator(func: Callable) -> Callable:
|
|
41
|
+
# Resolve target repository
|
|
42
|
+
target_repo: Repository | None
|
|
43
|
+
if isinstance(repo, Repository):
|
|
44
|
+
target_repo = repo
|
|
45
|
+
elif repo is not None:
|
|
46
|
+
try:
|
|
47
|
+
target_repo = Repository.open(repo)
|
|
48
|
+
except Exception:
|
|
49
|
+
target_repo = None
|
|
50
|
+
else:
|
|
51
|
+
try:
|
|
52
|
+
target_repo = Repository.open(".")
|
|
53
|
+
except Exception:
|
|
54
|
+
target_repo = None
|
|
55
|
+
|
|
56
|
+
func_name = name or func.__name__
|
|
57
|
+
|
|
58
|
+
# Auto-update manifest with docstring prompt and model config if enabled
|
|
59
|
+
if auto_manifest and target_repo is not None:
|
|
60
|
+
try:
|
|
61
|
+
manifest = target_repo.get_manifest()
|
|
62
|
+
updated = False
|
|
63
|
+
|
|
64
|
+
# 1. Capture docstring as prompt artifact
|
|
65
|
+
prompt_art = extract_docstring_prompt(func, name=f"{func_name}-prompt")
|
|
66
|
+
if prompt_art:
|
|
67
|
+
manifest.add_artifact(prompt_art)
|
|
68
|
+
updated = True
|
|
69
|
+
|
|
70
|
+
# 2. Capture explicit model config if specified
|
|
71
|
+
if model:
|
|
72
|
+
mc_art = ModelConfigArtifact(
|
|
73
|
+
name=f"{func_name}-model",
|
|
74
|
+
model=model,
|
|
75
|
+
provider=provider,
|
|
76
|
+
temperature=temperature,
|
|
77
|
+
)
|
|
78
|
+
manifest.add_artifact(mc_art)
|
|
79
|
+
updated = True
|
|
80
|
+
|
|
81
|
+
if updated:
|
|
82
|
+
target_repo.save_manifest(manifest, auto_hash=True)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
if inspect.iscoroutinefunction(func):
|
|
87
|
+
@functools.wraps(func)
|
|
88
|
+
async def async_wrapper(*args, **kwargs):
|
|
89
|
+
inputs_str = extract_inputs_from_args(func, args, kwargs)
|
|
90
|
+
start_time = time.perf_counter()
|
|
91
|
+
exec_meta = dict(metadata or {})
|
|
92
|
+
exec_meta["function"] = func.__name__
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
result = await func(*args, **kwargs)
|
|
96
|
+
elapsed = time.perf_counter() - start_time
|
|
97
|
+
duration_ms = max(1, int(elapsed * 1000))
|
|
98
|
+
|
|
99
|
+
output_text, tokens, detected_model = extract_llm_response(result)
|
|
100
|
+
if detected_model:
|
|
101
|
+
exec_meta["model"] = detected_model
|
|
102
|
+
elif model:
|
|
103
|
+
exec_meta["model"] = model
|
|
104
|
+
|
|
105
|
+
if target_repo is not None:
|
|
106
|
+
exec_obj = target_repo.record_execution(
|
|
107
|
+
inputs=inputs_str,
|
|
108
|
+
outputs=output_text,
|
|
109
|
+
duration_ms=duration_ms,
|
|
110
|
+
prompt_tokens=tokens.prompt_tokens,
|
|
111
|
+
completion_tokens=tokens.completion_tokens,
|
|
112
|
+
metadata=exec_meta,
|
|
113
|
+
)
|
|
114
|
+
setattr(async_wrapper, "last_execution", exec_obj)
|
|
115
|
+
|
|
116
|
+
return result
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
elapsed = time.perf_counter() - start_time
|
|
119
|
+
duration_ms = max(1, int(elapsed * 1000))
|
|
120
|
+
exec_meta["error"] = str(exc)
|
|
121
|
+
exec_meta["error_type"] = type(exc).__name__
|
|
122
|
+
|
|
123
|
+
if target_repo is not None:
|
|
124
|
+
target_repo.record_execution(
|
|
125
|
+
inputs=inputs_str,
|
|
126
|
+
outputs=f"Error: {exc}",
|
|
127
|
+
duration_ms=duration_ms,
|
|
128
|
+
metadata=exec_meta,
|
|
129
|
+
)
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
return async_wrapper
|
|
133
|
+
|
|
134
|
+
else:
|
|
135
|
+
@functools.wraps(func)
|
|
136
|
+
def sync_wrapper(*args, **kwargs):
|
|
137
|
+
inputs_str = extract_inputs_from_args(func, args, kwargs)
|
|
138
|
+
start_time = time.perf_counter()
|
|
139
|
+
exec_meta = dict(metadata or {})
|
|
140
|
+
exec_meta["function"] = func.__name__
|
|
141
|
+
|
|
142
|
+
# Check if model passed in runtime kwargs
|
|
143
|
+
detected_kwarg_mc = extract_model_config_from_kwargs(kwargs)
|
|
144
|
+
if detected_kwarg_mc and auto_manifest and target_repo is not None:
|
|
145
|
+
try:
|
|
146
|
+
manifest = target_repo.get_manifest()
|
|
147
|
+
manifest.add_artifact(detected_kwarg_mc)
|
|
148
|
+
target_repo.save_manifest(manifest, auto_hash=True)
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
result = func(*args, **kwargs)
|
|
154
|
+
elapsed = time.perf_counter() - start_time
|
|
155
|
+
duration_ms = max(1, int(elapsed * 1000))
|
|
156
|
+
|
|
157
|
+
output_text, tokens, detected_model = extract_llm_response(result)
|
|
158
|
+
if detected_model:
|
|
159
|
+
exec_meta["model"] = detected_model
|
|
160
|
+
elif model:
|
|
161
|
+
exec_meta["model"] = model
|
|
162
|
+
|
|
163
|
+
if target_repo is not None:
|
|
164
|
+
exec_obj = target_repo.record_execution(
|
|
165
|
+
inputs=inputs_str,
|
|
166
|
+
outputs=output_text,
|
|
167
|
+
duration_ms=duration_ms,
|
|
168
|
+
prompt_tokens=tokens.prompt_tokens,
|
|
169
|
+
completion_tokens=tokens.completion_tokens,
|
|
170
|
+
metadata=exec_meta,
|
|
171
|
+
)
|
|
172
|
+
setattr(sync_wrapper, "last_execution", exec_obj)
|
|
173
|
+
|
|
174
|
+
return result
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
elapsed = time.perf_counter() - start_time
|
|
177
|
+
duration_ms = max(1, int(elapsed * 1000))
|
|
178
|
+
exec_meta["error"] = str(exc)
|
|
179
|
+
exec_meta["error_type"] = type(exc).__name__
|
|
180
|
+
|
|
181
|
+
if target_repo is not None:
|
|
182
|
+
target_repo.record_execution(
|
|
183
|
+
inputs=inputs_str,
|
|
184
|
+
outputs=f"Error: {exc}",
|
|
185
|
+
duration_ms=duration_ms,
|
|
186
|
+
metadata=exec_meta,
|
|
187
|
+
)
|
|
188
|
+
raise
|
|
189
|
+
|
|
190
|
+
return sync_wrapper
|
|
191
|
+
|
|
192
|
+
if _func is None:
|
|
193
|
+
return decorator
|
|
194
|
+
return decorator(_func)
|