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.
@@ -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)