yamlgraph 0.1.1__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.
Potentially problematic release.
This version of yamlgraph might be problematic. Click here for more details.
- examples/__init__.py +1 -0
- examples/storyboard/__init__.py +1 -0
- examples/storyboard/generate_videos.py +335 -0
- examples/storyboard/nodes/__init__.py +10 -0
- examples/storyboard/nodes/animated_character_node.py +248 -0
- examples/storyboard/nodes/animated_image_node.py +138 -0
- examples/storyboard/nodes/character_node.py +162 -0
- examples/storyboard/nodes/image_node.py +118 -0
- examples/storyboard/nodes/replicate_tool.py +238 -0
- examples/storyboard/retry_images.py +118 -0
- tests/__init__.py +1 -0
- tests/conftest.py +178 -0
- tests/integration/__init__.py +1 -0
- tests/integration/test_animated_storyboard.py +63 -0
- tests/integration/test_cli_commands.py +242 -0
- tests/integration/test_map_demo.py +50 -0
- tests/integration/test_memory_demo.py +281 -0
- tests/integration/test_pipeline_flow.py +105 -0
- tests/integration/test_providers.py +163 -0
- tests/integration/test_resume.py +75 -0
- tests/unit/__init__.py +1 -0
- tests/unit/test_agent_nodes.py +200 -0
- tests/unit/test_checkpointer.py +212 -0
- tests/unit/test_cli.py +121 -0
- tests/unit/test_cli_package.py +81 -0
- tests/unit/test_compile_graph_map.py +132 -0
- tests/unit/test_conditions_routing.py +253 -0
- tests/unit/test_config.py +93 -0
- tests/unit/test_conversation_memory.py +270 -0
- tests/unit/test_database.py +145 -0
- tests/unit/test_deprecation.py +104 -0
- tests/unit/test_executor.py +60 -0
- tests/unit/test_executor_async.py +179 -0
- tests/unit/test_export.py +150 -0
- tests/unit/test_expressions.py +178 -0
- tests/unit/test_format_prompt.py +145 -0
- tests/unit/test_generic_report.py +200 -0
- tests/unit/test_graph_commands.py +327 -0
- tests/unit/test_graph_loader.py +299 -0
- tests/unit/test_graph_schema.py +193 -0
- tests/unit/test_inline_schema.py +151 -0
- tests/unit/test_issues.py +164 -0
- tests/unit/test_jinja2_prompts.py +85 -0
- tests/unit/test_langsmith.py +319 -0
- tests/unit/test_llm_factory.py +109 -0
- tests/unit/test_llm_factory_async.py +118 -0
- tests/unit/test_loops.py +403 -0
- tests/unit/test_map_node.py +144 -0
- tests/unit/test_no_backward_compat.py +56 -0
- tests/unit/test_node_factory.py +225 -0
- tests/unit/test_prompts.py +166 -0
- tests/unit/test_python_nodes.py +198 -0
- tests/unit/test_reliability.py +298 -0
- tests/unit/test_result_export.py +234 -0
- tests/unit/test_router.py +296 -0
- tests/unit/test_sanitize.py +99 -0
- tests/unit/test_schema_loader.py +295 -0
- tests/unit/test_shell_tools.py +229 -0
- tests/unit/test_state_builder.py +331 -0
- tests/unit/test_state_builder_map.py +104 -0
- tests/unit/test_state_config.py +197 -0
- tests/unit/test_template.py +190 -0
- tests/unit/test_tool_nodes.py +129 -0
- yamlgraph/__init__.py +35 -0
- yamlgraph/builder.py +110 -0
- yamlgraph/cli/__init__.py +139 -0
- yamlgraph/cli/__main__.py +6 -0
- yamlgraph/cli/commands.py +232 -0
- yamlgraph/cli/deprecation.py +92 -0
- yamlgraph/cli/graph_commands.py +382 -0
- yamlgraph/cli/validators.py +37 -0
- yamlgraph/config.py +67 -0
- yamlgraph/constants.py +66 -0
- yamlgraph/error_handlers.py +226 -0
- yamlgraph/executor.py +275 -0
- yamlgraph/executor_async.py +122 -0
- yamlgraph/graph_loader.py +337 -0
- yamlgraph/map_compiler.py +138 -0
- yamlgraph/models/__init__.py +36 -0
- yamlgraph/models/graph_schema.py +141 -0
- yamlgraph/models/schemas.py +124 -0
- yamlgraph/models/state_builder.py +236 -0
- yamlgraph/node_factory.py +240 -0
- yamlgraph/routing.py +87 -0
- yamlgraph/schema_loader.py +160 -0
- yamlgraph/storage/__init__.py +17 -0
- yamlgraph/storage/checkpointer.py +72 -0
- yamlgraph/storage/database.py +320 -0
- yamlgraph/storage/export.py +269 -0
- yamlgraph/tools/__init__.py +1 -0
- yamlgraph/tools/agent.py +235 -0
- yamlgraph/tools/nodes.py +124 -0
- yamlgraph/tools/python_tool.py +178 -0
- yamlgraph/tools/shell.py +205 -0
- yamlgraph/utils/__init__.py +47 -0
- yamlgraph/utils/conditions.py +157 -0
- yamlgraph/utils/expressions.py +111 -0
- yamlgraph/utils/langsmith.py +308 -0
- yamlgraph/utils/llm_factory.py +118 -0
- yamlgraph/utils/llm_factory_async.py +105 -0
- yamlgraph/utils/logging.py +127 -0
- yamlgraph/utils/prompts.py +116 -0
- yamlgraph/utils/sanitize.py +98 -0
- yamlgraph/utils/template.py +102 -0
- yamlgraph/utils/validators.py +181 -0
- yamlgraph-0.1.1.dist-info/METADATA +854 -0
- yamlgraph-0.1.1.dist-info/RECORD +111 -0
- yamlgraph-0.1.1.dist-info/WHEEL +5 -0
- yamlgraph-0.1.1.dist-info/entry_points.txt +2 -0
- yamlgraph-0.1.1.dist-info/licenses/LICENSE +21 -0
- yamlgraph-0.1.1.dist-info/top_level.txt +3 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""LangSmith Utilities - Tracing and observability helpers.
|
|
2
|
+
|
|
3
|
+
Provides functions for interacting with LangSmith traces,
|
|
4
|
+
printing execution trees, and logging run information.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from yamlgraph.config import WORKING_DIR
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_client() -> Any | None:
|
|
19
|
+
"""Get a LangSmith client if available.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
LangSmith Client instance or None if not configured
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
from langsmith import Client
|
|
26
|
+
|
|
27
|
+
# Support both LANGCHAIN_* and LANGSMITH_* env vars
|
|
28
|
+
api_key = os.environ.get("LANGCHAIN_API_KEY") or os.environ.get(
|
|
29
|
+
"LANGSMITH_API_KEY"
|
|
30
|
+
)
|
|
31
|
+
if not api_key:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
endpoint = (
|
|
35
|
+
os.environ.get("LANGCHAIN_ENDPOINT")
|
|
36
|
+
or os.environ.get("LANGSMITH_ENDPOINT")
|
|
37
|
+
or "https://api.smith.langchain.com"
|
|
38
|
+
)
|
|
39
|
+
return Client(api_url=endpoint, api_key=api_key)
|
|
40
|
+
except ImportError:
|
|
41
|
+
logger.debug("LangSmith package not installed, client unavailable")
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_project_name() -> str:
|
|
46
|
+
"""Get the current LangSmith project name.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Project name from environment or default
|
|
50
|
+
"""
|
|
51
|
+
return (
|
|
52
|
+
os.environ.get("LANGCHAIN_PROJECT")
|
|
53
|
+
or os.environ.get("LANGSMITH_PROJECT")
|
|
54
|
+
or "yamlgraph"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_tracing_enabled() -> bool:
|
|
59
|
+
"""Check if LangSmith tracing is enabled.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if tracing is enabled
|
|
63
|
+
"""
|
|
64
|
+
# Support both env var names and values
|
|
65
|
+
tracing_v2 = os.environ.get("LANGCHAIN_TRACING_V2", "").lower()
|
|
66
|
+
tracing = os.environ.get("LANGSMITH_TRACING", "").lower()
|
|
67
|
+
return tracing_v2 == "true" or tracing == "true"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_latest_run_id(project_name: str | None = None) -> str | None:
|
|
71
|
+
"""Get the ID of the most recent run.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
project_name: Optional project name (uses default if not provided)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Run ID string or None
|
|
78
|
+
"""
|
|
79
|
+
client = get_client()
|
|
80
|
+
if not client:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
project = project_name or get_project_name()
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
runs = list(client.list_runs(project_name=project, limit=1))
|
|
87
|
+
if runs:
|
|
88
|
+
return str(runs[0].id)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.warning("Could not get latest run: %s", e)
|
|
91
|
+
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def share_run(run_id: str | None = None) -> str | None:
|
|
96
|
+
"""Create a public share link for a run.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
run_id: Run ID (uses latest if not provided)
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Public URL string or None if failed
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
>>> url = share_run()
|
|
106
|
+
>>> print(url)
|
|
107
|
+
https://eu.smith.langchain.com/public/abc123.../r
|
|
108
|
+
"""
|
|
109
|
+
client = get_client()
|
|
110
|
+
if not client:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
if not run_id:
|
|
114
|
+
run_id = get_latest_run_id()
|
|
115
|
+
|
|
116
|
+
if not run_id:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
# Use the share_run method from LangSmith SDK
|
|
121
|
+
return client.share_run(run_id)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.warning("Could not share run: %s", e)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def read_run_shared_link(run_id: str) -> str | None:
|
|
128
|
+
"""Get existing share link for a run if it exists.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
run_id: The run ID to check
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Public URL string or None if not shared
|
|
135
|
+
"""
|
|
136
|
+
client = get_client()
|
|
137
|
+
if not client:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
return client.read_run_shared_link(run_id)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.debug("Could not read run shared link for %s: %s", run_id, e)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def print_run_tree(run_id: str | None = None, verbose: bool = False) -> None:
|
|
148
|
+
"""Print an execution tree for a run.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
run_id: Specific run ID (uses latest if not provided)
|
|
152
|
+
verbose: Include timing and status details
|
|
153
|
+
"""
|
|
154
|
+
client = get_client()
|
|
155
|
+
if not client:
|
|
156
|
+
logger.warning("LangSmith client not available")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
if not run_id:
|
|
160
|
+
run_id = get_latest_run_id()
|
|
161
|
+
|
|
162
|
+
if not run_id:
|
|
163
|
+
logger.warning("No run found")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
run = client.read_run(run_id)
|
|
168
|
+
_print_run_node(run, client, verbose=verbose, indent=0)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.warning("Error reading run: %s", e)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _print_run_node(
|
|
174
|
+
run,
|
|
175
|
+
client,
|
|
176
|
+
verbose: bool = False,
|
|
177
|
+
indent: int = 0,
|
|
178
|
+
is_last: bool = True,
|
|
179
|
+
prefix: str = "",
|
|
180
|
+
):
|
|
181
|
+
"""Recursively print a run node and its children in tree format.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
run: The LangSmith run object
|
|
185
|
+
client: LangSmith client
|
|
186
|
+
verbose: Include timing details
|
|
187
|
+
indent: Current indentation level
|
|
188
|
+
is_last: Whether this is the last sibling
|
|
189
|
+
prefix: Prefix string for tree drawing
|
|
190
|
+
"""
|
|
191
|
+
# Status emoji
|
|
192
|
+
if run.status == "success":
|
|
193
|
+
status = "✅"
|
|
194
|
+
elif run.status == "error":
|
|
195
|
+
status = "❌"
|
|
196
|
+
else:
|
|
197
|
+
status = "⏳"
|
|
198
|
+
|
|
199
|
+
# Timing
|
|
200
|
+
timing = ""
|
|
201
|
+
if run.end_time and run.start_time:
|
|
202
|
+
duration = (run.end_time - run.start_time).total_seconds()
|
|
203
|
+
timing = f" ({duration:.1f}s)"
|
|
204
|
+
|
|
205
|
+
# Tree connectors
|
|
206
|
+
if indent == 0:
|
|
207
|
+
connector = "📊 "
|
|
208
|
+
new_prefix = ""
|
|
209
|
+
else:
|
|
210
|
+
connector = "└─ " if is_last else "├─ "
|
|
211
|
+
new_prefix = prefix + (" " if is_last else "│ ")
|
|
212
|
+
|
|
213
|
+
# Clean up run name for display
|
|
214
|
+
display_name = run.name
|
|
215
|
+
if display_name.startswith("Chat"):
|
|
216
|
+
display_name = f"🤖 {display_name}"
|
|
217
|
+
elif "generate" in display_name.lower():
|
|
218
|
+
display_name = f"📝 {display_name}"
|
|
219
|
+
elif "analyze" in display_name.lower():
|
|
220
|
+
display_name = f"🔍 {display_name}"
|
|
221
|
+
elif "summarize" in display_name.lower():
|
|
222
|
+
display_name = f"📊 {display_name}"
|
|
223
|
+
|
|
224
|
+
logger.info("%s%s%s%s %s", prefix, connector, display_name, timing, status)
|
|
225
|
+
|
|
226
|
+
# Get child runs
|
|
227
|
+
try:
|
|
228
|
+
children = list(
|
|
229
|
+
client.list_runs(
|
|
230
|
+
parent_run_id=run.id,
|
|
231
|
+
limit=50,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
# Sort by start time to show in execution order
|
|
235
|
+
children.sort(key=lambda r: r.start_time or datetime.min)
|
|
236
|
+
|
|
237
|
+
for i, child in enumerate(children):
|
|
238
|
+
child_is_last = i == len(children) - 1
|
|
239
|
+
_print_run_node(
|
|
240
|
+
child,
|
|
241
|
+
client,
|
|
242
|
+
verbose=verbose,
|
|
243
|
+
indent=indent + 1,
|
|
244
|
+
is_last=child_is_last,
|
|
245
|
+
prefix=new_prefix,
|
|
246
|
+
)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.debug("Could not fetch child runs for %s: %s", run.id, e)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def log_execution(
|
|
252
|
+
step_name: str,
|
|
253
|
+
inputs: dict | None = None,
|
|
254
|
+
outputs: dict | None = None,
|
|
255
|
+
log_dir: str | Path | None = None,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Log execution details to a file.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
step_name: Name of the pipeline step
|
|
261
|
+
inputs: Input data for the step
|
|
262
|
+
outputs: Output data from the step
|
|
263
|
+
log_dir: Directory for log files (default: outputs/logs)
|
|
264
|
+
"""
|
|
265
|
+
import json
|
|
266
|
+
|
|
267
|
+
if log_dir is None:
|
|
268
|
+
log_dir = WORKING_DIR / "outputs" / "logs"
|
|
269
|
+
log_path = Path(log_dir)
|
|
270
|
+
log_path.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
|
|
272
|
+
log_file = log_path / f"{datetime.now().strftime('%Y%m%d')}_execution.jsonl"
|
|
273
|
+
|
|
274
|
+
entry = {
|
|
275
|
+
"timestamp": datetime.now().isoformat(),
|
|
276
|
+
"step": step_name,
|
|
277
|
+
"inputs": inputs or {},
|
|
278
|
+
"outputs": outputs or {},
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
with open(log_file, "a") as f:
|
|
282
|
+
f.write(json.dumps(entry, default=str) + "\n")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def get_run_url(run_id: str | None = None) -> str | None:
|
|
286
|
+
"""Get the LangSmith URL for a run.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
run_id: Run ID (uses latest if not provided)
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
URL string or None
|
|
293
|
+
"""
|
|
294
|
+
if not run_id:
|
|
295
|
+
run_id = get_latest_run_id()
|
|
296
|
+
|
|
297
|
+
if not run_id:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
endpoint = os.environ.get("LANGCHAIN_ENDPOINT", "https://api.smith.langchain.com")
|
|
301
|
+
project = get_project_name()
|
|
302
|
+
|
|
303
|
+
# Convert API endpoint to web URL
|
|
304
|
+
web_url = endpoint.replace("api.", "").replace("/api", "")
|
|
305
|
+
if "smith.langchain" in web_url:
|
|
306
|
+
return f"{web_url}/o/default/projects/p/{project}/runs/{run_id}"
|
|
307
|
+
|
|
308
|
+
return f"{web_url}/projects/{project}/runs/{run_id}"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""LLM Factory - Multi-provider abstraction for language models.
|
|
2
|
+
|
|
3
|
+
This module provides a simple factory pattern for creating LLM instances
|
|
4
|
+
across different providers (Anthropic, Mistral, OpenAI).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
13
|
+
|
|
14
|
+
from yamlgraph.config import DEFAULT_MODELS
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Type alias for supported providers
|
|
19
|
+
ProviderType = Literal["anthropic", "mistral", "openai"]
|
|
20
|
+
|
|
21
|
+
# Thread-safe cache for LLM instances
|
|
22
|
+
_llm_cache: dict[tuple, BaseChatModel] = {}
|
|
23
|
+
_cache_lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_llm(
|
|
27
|
+
provider: ProviderType | None = None,
|
|
28
|
+
model: str | None = None,
|
|
29
|
+
temperature: float = 0.7,
|
|
30
|
+
) -> BaseChatModel:
|
|
31
|
+
"""Create an LLM instance with multi-provider support.
|
|
32
|
+
|
|
33
|
+
Supports Anthropic (default), Mistral, and OpenAI providers.
|
|
34
|
+
Provider can be specified via parameter or PROVIDER environment variable.
|
|
35
|
+
Model can be specified via parameter or {PROVIDER}_MODEL environment variable.
|
|
36
|
+
|
|
37
|
+
LLM instances are cached by (provider, model, temperature) to improve performance.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
provider: LLM provider ("anthropic", "mistral", "openai").
|
|
41
|
+
Defaults to PROVIDER env var or "anthropic".
|
|
42
|
+
model: Model name. Defaults to {PROVIDER}_MODEL env var or provider default.
|
|
43
|
+
temperature: Temperature for generation (0.0-1.0).
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Configured LLM instance.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: If provider is invalid.
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
>>> # Use default Anthropic
|
|
53
|
+
>>> llm = create_llm(temperature=0.7)
|
|
54
|
+
|
|
55
|
+
>>> # Override provider
|
|
56
|
+
>>> llm = create_llm(provider="mistral", temperature=0.8)
|
|
57
|
+
|
|
58
|
+
>>> # Custom model
|
|
59
|
+
>>> llm = create_llm(provider="openai", model="gpt-4o-mini")
|
|
60
|
+
"""
|
|
61
|
+
# Determine provider (parameter > env var > default)
|
|
62
|
+
selected_provider = provider or os.getenv("PROVIDER") or "anthropic"
|
|
63
|
+
|
|
64
|
+
# Validate provider
|
|
65
|
+
if selected_provider not in DEFAULT_MODELS:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Invalid provider: {selected_provider}. "
|
|
68
|
+
f"Must be one of: {', '.join(DEFAULT_MODELS.keys())}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Determine model (parameter > env var > default)
|
|
72
|
+
# Note: DEFAULT_MODELS already handles env var via config.py
|
|
73
|
+
selected_model = model or DEFAULT_MODELS[selected_provider]
|
|
74
|
+
|
|
75
|
+
# Create cache key
|
|
76
|
+
cache_key = (selected_provider, selected_model, temperature)
|
|
77
|
+
|
|
78
|
+
# Thread-safe cache access
|
|
79
|
+
with _cache_lock:
|
|
80
|
+
# Return cached instance if available
|
|
81
|
+
if cache_key in _llm_cache:
|
|
82
|
+
logger.debug(
|
|
83
|
+
f"Using cached LLM: {selected_provider}/{selected_model} (temp={temperature})"
|
|
84
|
+
)
|
|
85
|
+
return _llm_cache[cache_key]
|
|
86
|
+
|
|
87
|
+
# Create new LLM instance
|
|
88
|
+
logger.info(
|
|
89
|
+
f"Creating LLM: {selected_provider}/{selected_model} (temp={temperature})"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if selected_provider == "mistral":
|
|
93
|
+
from langchain_mistralai import ChatMistralAI
|
|
94
|
+
|
|
95
|
+
llm = ChatMistralAI(model=selected_model, temperature=temperature)
|
|
96
|
+
elif selected_provider == "openai":
|
|
97
|
+
from langchain_openai import ChatOpenAI
|
|
98
|
+
|
|
99
|
+
llm = ChatOpenAI(model=selected_model, temperature=temperature)
|
|
100
|
+
else: # anthropic (default)
|
|
101
|
+
from langchain_anthropic import ChatAnthropic
|
|
102
|
+
|
|
103
|
+
llm = ChatAnthropic(model=selected_model, temperature=temperature)
|
|
104
|
+
|
|
105
|
+
# Cache the instance
|
|
106
|
+
_llm_cache[cache_key] = llm
|
|
107
|
+
|
|
108
|
+
return llm
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def clear_cache() -> None:
|
|
112
|
+
"""Clear the LLM instance cache.
|
|
113
|
+
|
|
114
|
+
Useful for testing or when you want to force recreation of LLM instances.
|
|
115
|
+
"""
|
|
116
|
+
with _cache_lock:
|
|
117
|
+
_llm_cache.clear()
|
|
118
|
+
logger.debug("LLM cache cleared")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Async LLM Factory - Async versions of LLM creation.
|
|
2
|
+
|
|
3
|
+
This module provides async-compatible LLM creation with support for
|
|
4
|
+
non-blocking I/O operations in async contexts.
|
|
5
|
+
|
|
6
|
+
Note: This module is a foundation for future async support. Currently,
|
|
7
|
+
LangChain's LLM implementations use sync HTTP clients internally, so
|
|
8
|
+
this wraps them for use in async contexts via run_in_executor.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from functools import partial
|
|
15
|
+
from typing import TypeVar
|
|
16
|
+
|
|
17
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
18
|
+
from langchain_core.messages import BaseMessage
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from yamlgraph.utils.llm_factory import ProviderType, create_llm
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T", bound=BaseModel)
|
|
26
|
+
|
|
27
|
+
# Shared executor for running sync LLM calls
|
|
28
|
+
_executor: ThreadPoolExecutor | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_executor() -> ThreadPoolExecutor:
|
|
32
|
+
"""Get or create the shared thread pool executor."""
|
|
33
|
+
global _executor
|
|
34
|
+
if _executor is None:
|
|
35
|
+
_executor = ThreadPoolExecutor(max_workers=4)
|
|
36
|
+
return _executor
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def create_llm_async(
|
|
40
|
+
provider: ProviderType | None = None,
|
|
41
|
+
model: str | None = None,
|
|
42
|
+
temperature: float = 0.7,
|
|
43
|
+
) -> BaseChatModel:
|
|
44
|
+
"""Create an LLM instance asynchronously.
|
|
45
|
+
|
|
46
|
+
Currently wraps the sync create_llm. Future versions may use
|
|
47
|
+
native async LLM implementations.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
provider: LLM provider ("anthropic", "mistral", "openai")
|
|
51
|
+
model: Model name
|
|
52
|
+
temperature: Temperature for generation
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Configured LLM instance
|
|
56
|
+
"""
|
|
57
|
+
loop = asyncio.get_event_loop()
|
|
58
|
+
return await loop.run_in_executor(
|
|
59
|
+
get_executor(),
|
|
60
|
+
partial(create_llm, provider=provider, model=model, temperature=temperature),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def invoke_async(
|
|
65
|
+
llm: BaseChatModel,
|
|
66
|
+
messages: list[BaseMessage],
|
|
67
|
+
output_model: type[T] | None = None,
|
|
68
|
+
) -> T | str:
|
|
69
|
+
"""Invoke LLM asynchronously.
|
|
70
|
+
|
|
71
|
+
Runs the sync invoke in a thread pool to avoid blocking.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
llm: The LLM instance
|
|
75
|
+
messages: Messages to send
|
|
76
|
+
output_model: Optional Pydantic model for structured output
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
LLM response (parsed model or string)
|
|
80
|
+
"""
|
|
81
|
+
loop = asyncio.get_event_loop()
|
|
82
|
+
|
|
83
|
+
def sync_invoke() -> T | str:
|
|
84
|
+
if output_model:
|
|
85
|
+
structured_llm = llm.with_structured_output(output_model)
|
|
86
|
+
return structured_llm.invoke(messages)
|
|
87
|
+
else:
|
|
88
|
+
response = llm.invoke(messages)
|
|
89
|
+
return response.content
|
|
90
|
+
|
|
91
|
+
return await loop.run_in_executor(get_executor(), sync_invoke)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def shutdown_executor() -> None:
|
|
95
|
+
"""Shutdown the thread pool executor.
|
|
96
|
+
|
|
97
|
+
Call this during application shutdown to clean up resources.
|
|
98
|
+
"""
|
|
99
|
+
global _executor
|
|
100
|
+
if _executor is not None:
|
|
101
|
+
_executor.shutdown(wait=True)
|
|
102
|
+
_executor = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = ["create_llm_async", "invoke_async", "shutdown_executor"]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Structured logging configuration for yamlgraph.
|
|
2
|
+
|
|
3
|
+
Provides consistent logging across all modules with JSON-formatted
|
|
4
|
+
output for production environments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StructuredFormatter(logging.Formatter):
|
|
14
|
+
"""Formatter that outputs structured log messages.
|
|
15
|
+
|
|
16
|
+
In production (LOG_FORMAT=json), outputs JSON lines.
|
|
17
|
+
In development, outputs human-readable format.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, use_json: bool = False):
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.use_json = use_json
|
|
23
|
+
|
|
24
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
25
|
+
"""Format a log record."""
|
|
26
|
+
if self.use_json:
|
|
27
|
+
import json
|
|
28
|
+
|
|
29
|
+
log_data = {
|
|
30
|
+
"timestamp": self.formatTime(record),
|
|
31
|
+
"level": record.levelname,
|
|
32
|
+
"logger": record.name,
|
|
33
|
+
"message": record.getMessage(),
|
|
34
|
+
}
|
|
35
|
+
# Add extra fields if present
|
|
36
|
+
if hasattr(record, "extra"):
|
|
37
|
+
log_data.update(record.extra)
|
|
38
|
+
if record.exc_info:
|
|
39
|
+
log_data["exception"] = self.formatException(record.exc_info)
|
|
40
|
+
return json.dumps(log_data)
|
|
41
|
+
else:
|
|
42
|
+
# Human-readable format
|
|
43
|
+
base = f"{self.formatTime(record)} [{record.levelname}] {record.name}: {record.getMessage()}"
|
|
44
|
+
if record.exc_info:
|
|
45
|
+
base += f"\n{self.formatException(record.exc_info)}"
|
|
46
|
+
return base
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def setup_logging(
|
|
50
|
+
level: str | None = None,
|
|
51
|
+
use_json: bool | None = None,
|
|
52
|
+
) -> logging.Logger:
|
|
53
|
+
"""Configure logging for yamlgraph.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR).
|
|
57
|
+
Defaults to LOG_LEVEL env var or INFO.
|
|
58
|
+
use_json: If True, output JSON lines.
|
|
59
|
+
Defaults to LOG_FORMAT=json env var.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Root logger for the yamlgraph package
|
|
63
|
+
"""
|
|
64
|
+
if level is None:
|
|
65
|
+
level = os.getenv("LOG_LEVEL", "INFO")
|
|
66
|
+
|
|
67
|
+
if use_json is None:
|
|
68
|
+
use_json = os.getenv("LOG_FORMAT", "").lower() == "json"
|
|
69
|
+
|
|
70
|
+
# Get the yamlgraph logger
|
|
71
|
+
logger = logging.getLogger("yamlgraph")
|
|
72
|
+
logger.setLevel(getattr(logging, level.upper()))
|
|
73
|
+
|
|
74
|
+
# Remove existing handlers
|
|
75
|
+
logger.handlers.clear()
|
|
76
|
+
|
|
77
|
+
# Add handler with formatter
|
|
78
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
79
|
+
handler.setFormatter(StructuredFormatter(use_json=use_json))
|
|
80
|
+
logger.addHandler(handler)
|
|
81
|
+
|
|
82
|
+
# Don't propagate to root logger
|
|
83
|
+
logger.propagate = False
|
|
84
|
+
|
|
85
|
+
return logger
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_logger(name: str) -> logging.Logger:
|
|
89
|
+
"""Get a logger for a specific module.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
name: Module name (typically __name__)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Logger instance
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
>>> logger = get_logger(__name__)
|
|
99
|
+
>>> logger.info("Processing started", extra={"topic": "AI"})
|
|
100
|
+
"""
|
|
101
|
+
return logging.getLogger(name)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def log_with_context(
|
|
105
|
+
logger: logging.Logger,
|
|
106
|
+
level: int,
|
|
107
|
+
message: str,
|
|
108
|
+
**context: Any,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Log a message with additional context fields.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
logger: Logger instance
|
|
114
|
+
level: Log level (logging.INFO, etc.)
|
|
115
|
+
message: Log message
|
|
116
|
+
**context: Additional context fields
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
>>> log_with_context(logger, logging.INFO, "Node completed",
|
|
120
|
+
... node="generate", duration=1.5)
|
|
121
|
+
"""
|
|
122
|
+
extra = {"extra": context} if context else {}
|
|
123
|
+
logger.log(level, message, extra=extra)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Initialize logging on import
|
|
127
|
+
_root_logger = setup_logging()
|