hexdag 0.5.0.dev1__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 (261) hide show
  1. hexdag/__init__.py +116 -0
  2. hexdag/__main__.py +30 -0
  3. hexdag/adapters/executors/__init__.py +5 -0
  4. hexdag/adapters/executors/local_executor.py +316 -0
  5. hexdag/builtin/__init__.py +6 -0
  6. hexdag/builtin/adapters/__init__.py +51 -0
  7. hexdag/builtin/adapters/anthropic/__init__.py +5 -0
  8. hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
  9. hexdag/builtin/adapters/database/__init__.py +6 -0
  10. hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
  11. hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
  12. hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
  13. hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
  14. hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
  15. hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
  16. hexdag/builtin/adapters/local/README.md +59 -0
  17. hexdag/builtin/adapters/local/__init__.py +7 -0
  18. hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
  19. hexdag/builtin/adapters/memory/__init__.py +47 -0
  20. hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
  21. hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
  22. hexdag/builtin/adapters/memory/schemas.py +57 -0
  23. hexdag/builtin/adapters/memory/session_memory.py +178 -0
  24. hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
  25. hexdag/builtin/adapters/memory/state_memory.py +280 -0
  26. hexdag/builtin/adapters/mock/README.md +89 -0
  27. hexdag/builtin/adapters/mock/__init__.py +15 -0
  28. hexdag/builtin/adapters/mock/hexdag.toml +50 -0
  29. hexdag/builtin/adapters/mock/mock_database.py +225 -0
  30. hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
  31. hexdag/builtin/adapters/mock/mock_llm.py +177 -0
  32. hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
  33. hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
  34. hexdag/builtin/adapters/openai/__init__.py +5 -0
  35. hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
  36. hexdag/builtin/adapters/secret/__init__.py +7 -0
  37. hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
  38. hexdag/builtin/adapters/unified_tool_router.py +280 -0
  39. hexdag/builtin/macros/__init__.py +17 -0
  40. hexdag/builtin/macros/conversation_agent.py +390 -0
  41. hexdag/builtin/macros/llm_macro.py +151 -0
  42. hexdag/builtin/macros/reasoning_agent.py +423 -0
  43. hexdag/builtin/macros/tool_macro.py +380 -0
  44. hexdag/builtin/nodes/__init__.py +38 -0
  45. hexdag/builtin/nodes/_discovery.py +123 -0
  46. hexdag/builtin/nodes/agent_node.py +696 -0
  47. hexdag/builtin/nodes/base_node_factory.py +242 -0
  48. hexdag/builtin/nodes/composite_node.py +926 -0
  49. hexdag/builtin/nodes/data_node.py +201 -0
  50. hexdag/builtin/nodes/expression_node.py +487 -0
  51. hexdag/builtin/nodes/function_node.py +454 -0
  52. hexdag/builtin/nodes/llm_node.py +491 -0
  53. hexdag/builtin/nodes/loop_node.py +920 -0
  54. hexdag/builtin/nodes/mapped_input.py +518 -0
  55. hexdag/builtin/nodes/port_call_node.py +269 -0
  56. hexdag/builtin/nodes/tool_call_node.py +195 -0
  57. hexdag/builtin/nodes/tool_utils.py +390 -0
  58. hexdag/builtin/prompts/__init__.py +68 -0
  59. hexdag/builtin/prompts/base.py +422 -0
  60. hexdag/builtin/prompts/chat_prompts.py +303 -0
  61. hexdag/builtin/prompts/error_correction_prompts.py +320 -0
  62. hexdag/builtin/prompts/tool_prompts.py +160 -0
  63. hexdag/builtin/tools/builtin_tools.py +84 -0
  64. hexdag/builtin/tools/database_tools.py +164 -0
  65. hexdag/cli/__init__.py +17 -0
  66. hexdag/cli/__main__.py +7 -0
  67. hexdag/cli/commands/__init__.py +27 -0
  68. hexdag/cli/commands/build_cmd.py +812 -0
  69. hexdag/cli/commands/create_cmd.py +208 -0
  70. hexdag/cli/commands/docs_cmd.py +293 -0
  71. hexdag/cli/commands/generate_types_cmd.py +252 -0
  72. hexdag/cli/commands/init_cmd.py +188 -0
  73. hexdag/cli/commands/pipeline_cmd.py +494 -0
  74. hexdag/cli/commands/plugin_dev_cmd.py +529 -0
  75. hexdag/cli/commands/plugins_cmd.py +441 -0
  76. hexdag/cli/commands/studio_cmd.py +101 -0
  77. hexdag/cli/commands/validate_cmd.py +221 -0
  78. hexdag/cli/main.py +84 -0
  79. hexdag/core/__init__.py +83 -0
  80. hexdag/core/config/__init__.py +20 -0
  81. hexdag/core/config/loader.py +479 -0
  82. hexdag/core/config/models.py +150 -0
  83. hexdag/core/configurable.py +294 -0
  84. hexdag/core/context/__init__.py +37 -0
  85. hexdag/core/context/execution_context.py +378 -0
  86. hexdag/core/docs/__init__.py +26 -0
  87. hexdag/core/docs/extractors.py +678 -0
  88. hexdag/core/docs/generators.py +890 -0
  89. hexdag/core/docs/models.py +120 -0
  90. hexdag/core/domain/__init__.py +10 -0
  91. hexdag/core/domain/dag.py +1225 -0
  92. hexdag/core/exceptions.py +234 -0
  93. hexdag/core/expression_parser.py +569 -0
  94. hexdag/core/logging.py +449 -0
  95. hexdag/core/models/__init__.py +17 -0
  96. hexdag/core/models/base.py +138 -0
  97. hexdag/core/orchestration/__init__.py +46 -0
  98. hexdag/core/orchestration/body_executor.py +481 -0
  99. hexdag/core/orchestration/components/__init__.py +97 -0
  100. hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
  101. hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
  102. hexdag/core/orchestration/components/execution_coordinator.py +360 -0
  103. hexdag/core/orchestration/components/health_check_manager.py +176 -0
  104. hexdag/core/orchestration/components/input_mapper.py +143 -0
  105. hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
  106. hexdag/core/orchestration/components/node_executor.py +377 -0
  107. hexdag/core/orchestration/components/secret_manager.py +202 -0
  108. hexdag/core/orchestration/components/wave_executor.py +158 -0
  109. hexdag/core/orchestration/constants.py +17 -0
  110. hexdag/core/orchestration/events/README.md +312 -0
  111. hexdag/core/orchestration/events/__init__.py +104 -0
  112. hexdag/core/orchestration/events/batching.py +330 -0
  113. hexdag/core/orchestration/events/decorators.py +139 -0
  114. hexdag/core/orchestration/events/events.py +573 -0
  115. hexdag/core/orchestration/events/observers/__init__.py +30 -0
  116. hexdag/core/orchestration/events/observers/core_observers.py +690 -0
  117. hexdag/core/orchestration/events/observers/models.py +111 -0
  118. hexdag/core/orchestration/events/taxonomy.py +269 -0
  119. hexdag/core/orchestration/hook_context.py +237 -0
  120. hexdag/core/orchestration/hooks.py +437 -0
  121. hexdag/core/orchestration/models.py +418 -0
  122. hexdag/core/orchestration/orchestrator.py +910 -0
  123. hexdag/core/orchestration/orchestrator_factory.py +275 -0
  124. hexdag/core/orchestration/port_wrappers.py +327 -0
  125. hexdag/core/orchestration/prompt/__init__.py +32 -0
  126. hexdag/core/orchestration/prompt/template.py +332 -0
  127. hexdag/core/pipeline_builder/__init__.py +21 -0
  128. hexdag/core/pipeline_builder/component_instantiator.py +386 -0
  129. hexdag/core/pipeline_builder/include_tag.py +265 -0
  130. hexdag/core/pipeline_builder/pipeline_config.py +133 -0
  131. hexdag/core/pipeline_builder/py_tag.py +223 -0
  132. hexdag/core/pipeline_builder/tag_discovery.py +268 -0
  133. hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
  134. hexdag/core/pipeline_builder/yaml_validator.py +569 -0
  135. hexdag/core/ports/__init__.py +65 -0
  136. hexdag/core/ports/api_call.py +133 -0
  137. hexdag/core/ports/database.py +489 -0
  138. hexdag/core/ports/embedding.py +215 -0
  139. hexdag/core/ports/executor.py +237 -0
  140. hexdag/core/ports/file_storage.py +117 -0
  141. hexdag/core/ports/healthcheck.py +87 -0
  142. hexdag/core/ports/llm.py +551 -0
  143. hexdag/core/ports/memory.py +70 -0
  144. hexdag/core/ports/observer_manager.py +130 -0
  145. hexdag/core/ports/secret.py +145 -0
  146. hexdag/core/ports/tool_router.py +94 -0
  147. hexdag/core/ports_builder.py +623 -0
  148. hexdag/core/protocols.py +273 -0
  149. hexdag/core/resolver.py +304 -0
  150. hexdag/core/schema/__init__.py +9 -0
  151. hexdag/core/schema/generator.py +742 -0
  152. hexdag/core/secrets.py +242 -0
  153. hexdag/core/types.py +413 -0
  154. hexdag/core/utils/async_warnings.py +206 -0
  155. hexdag/core/utils/schema_conversion.py +78 -0
  156. hexdag/core/utils/sql_validation.py +86 -0
  157. hexdag/core/validation/secure_json.py +148 -0
  158. hexdag/core/yaml_macro.py +517 -0
  159. hexdag/mcp_server.py +3120 -0
  160. hexdag/studio/__init__.py +10 -0
  161. hexdag/studio/build_ui.py +92 -0
  162. hexdag/studio/server/__init__.py +1 -0
  163. hexdag/studio/server/main.py +100 -0
  164. hexdag/studio/server/routes/__init__.py +9 -0
  165. hexdag/studio/server/routes/execute.py +208 -0
  166. hexdag/studio/server/routes/export.py +558 -0
  167. hexdag/studio/server/routes/files.py +207 -0
  168. hexdag/studio/server/routes/plugins.py +419 -0
  169. hexdag/studio/server/routes/validate.py +220 -0
  170. hexdag/studio/ui/index.html +13 -0
  171. hexdag/studio/ui/package-lock.json +2992 -0
  172. hexdag/studio/ui/package.json +31 -0
  173. hexdag/studio/ui/postcss.config.js +6 -0
  174. hexdag/studio/ui/public/hexdag.svg +5 -0
  175. hexdag/studio/ui/src/App.tsx +251 -0
  176. hexdag/studio/ui/src/components/Canvas.tsx +408 -0
  177. hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
  178. hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
  179. hexdag/studio/ui/src/components/Header.tsx +181 -0
  180. hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
  181. hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
  182. hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
  183. hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
  184. hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
  185. hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
  186. hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
  187. hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
  188. hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
  189. hexdag/studio/ui/src/components/index.ts +8 -0
  190. hexdag/studio/ui/src/index.css +92 -0
  191. hexdag/studio/ui/src/main.tsx +10 -0
  192. hexdag/studio/ui/src/types/index.ts +123 -0
  193. hexdag/studio/ui/src/vite-env.d.ts +1 -0
  194. hexdag/studio/ui/tailwind.config.js +29 -0
  195. hexdag/studio/ui/tsconfig.json +37 -0
  196. hexdag/studio/ui/tsconfig.node.json +13 -0
  197. hexdag/studio/ui/vite.config.ts +35 -0
  198. hexdag/visualization/__init__.py +69 -0
  199. hexdag/visualization/dag_visualizer.py +1020 -0
  200. hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
  201. hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
  202. hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
  203. hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
  204. hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
  205. hexdag_plugins/.gitignore +43 -0
  206. hexdag_plugins/README.md +73 -0
  207. hexdag_plugins/__init__.py +1 -0
  208. hexdag_plugins/azure/LICENSE +21 -0
  209. hexdag_plugins/azure/README.md +414 -0
  210. hexdag_plugins/azure/__init__.py +21 -0
  211. hexdag_plugins/azure/azure_blob_adapter.py +450 -0
  212. hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
  213. hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
  214. hexdag_plugins/azure/azure_openai_adapter.py +415 -0
  215. hexdag_plugins/azure/pyproject.toml +107 -0
  216. hexdag_plugins/azure/tests/__init__.py +1 -0
  217. hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
  218. hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
  219. hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
  220. hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
  221. hexdag_plugins/hexdag_etl/README.md +168 -0
  222. hexdag_plugins/hexdag_etl/__init__.py +53 -0
  223. hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
  224. hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
  225. hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
  226. hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
  227. hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
  228. hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
  229. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
  230. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
  231. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
  232. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
  233. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
  234. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
  235. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
  236. hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
  237. hexdag_plugins/hexdag_etl/test_transform.py +54 -0
  238. hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
  239. hexdag_plugins/mysql_adapter/LICENSE +21 -0
  240. hexdag_plugins/mysql_adapter/README.md +224 -0
  241. hexdag_plugins/mysql_adapter/__init__.py +6 -0
  242. hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
  243. hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
  244. hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
  245. hexdag_plugins/storage/README.md +184 -0
  246. hexdag_plugins/storage/__init__.py +19 -0
  247. hexdag_plugins/storage/file/__init__.py +5 -0
  248. hexdag_plugins/storage/file/local.py +325 -0
  249. hexdag_plugins/storage/ports/__init__.py +5 -0
  250. hexdag_plugins/storage/ports/vector_store.py +236 -0
  251. hexdag_plugins/storage/sql/__init__.py +7 -0
  252. hexdag_plugins/storage/sql/base.py +187 -0
  253. hexdag_plugins/storage/sql/mysql.py +27 -0
  254. hexdag_plugins/storage/sql/postgresql.py +27 -0
  255. hexdag_plugins/storage/tests/__init__.py +1 -0
  256. hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
  257. hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
  258. hexdag_plugins/storage/vector/__init__.py +7 -0
  259. hexdag_plugins/storage/vector/chromadb.py +223 -0
  260. hexdag_plugins/storage/vector/in_memory.py +285 -0
  261. hexdag_plugins/storage/vector/pgvector.py +502 -0
@@ -0,0 +1,491 @@
1
+ """LLMNode - Unified LLM node for prompt building, API calls, and parsing.
2
+
3
+ This is the primary LLM node in hexdag, providing an n8n-style unified interface
4
+ for all LLM interactions. It combines:
5
+ - Prompt templating (Jinja2-style variable substitution)
6
+ - LLM API calls (via the llm port)
7
+ - Optional structured output parsing (JSON/Pydantic validation)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import time
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from pydantic import BaseModel, ValidationError
18
+
19
+ from hexdag.core.context import get_port
20
+ from hexdag.core.exceptions import ParseError
21
+ from hexdag.core.logging import get_logger
22
+ from hexdag.core.orchestration.prompt.template import PromptTemplate
23
+ from hexdag.core.ports.llm import Message
24
+ from hexdag.core.protocols import to_dict
25
+
26
+ from .base_node_factory import BaseNodeFactory
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Callable
30
+
31
+ from hexdag.core.domain.dag import NodeSpec
32
+ from hexdag.core.orchestration.prompt import PromptInput, TemplateType
33
+
34
+ logger = get_logger(__name__)
35
+
36
+
37
+ def _convert_dicts_to_messages(message_dicts: list[dict[str, str]]) -> list[Message]:
38
+ """Convert list of message dicts to Message objects."""
39
+ return [Message(**msg) for msg in message_dicts]
40
+
41
+
42
+ class LLMNode(BaseNodeFactory):
43
+ """Unified LLM node - prompt building, API calls, and optional parsing.
44
+
45
+ This is the primary node for LLM interactions in hexdag. It provides a simple,
46
+ n8n-style interface that handles the complete LLM workflow in a single node.
47
+
48
+ Capabilities
49
+ ------------
50
+ 1. **Prompt Templating**: Jinja2-style variable substitution ({{variable}})
51
+ 2. **LLM API Calls**: Calls the configured LLM port
52
+ 3. **Structured Output**: Optional JSON parsing with Pydantic validation
53
+ 4. **System Prompts**: Optional system message support
54
+ 5. **Message History**: Support for conversation context
55
+
56
+ Examples
57
+ --------
58
+ Simple text generation::
59
+
60
+ llm = LLMNode()
61
+ spec = llm(
62
+ name="summarizer",
63
+ prompt_template="Summarize this text: {{text}}"
64
+ )
65
+
66
+ With system prompt::
67
+
68
+ spec = llm(
69
+ name="assistant",
70
+ prompt_template="Answer: {{question}}",
71
+ system_prompt="You are a helpful assistant."
72
+ )
73
+
74
+ Structured output with JSON parsing::
75
+
76
+ from pydantic import BaseModel
77
+
78
+ class Analysis(BaseModel):
79
+ sentiment: str
80
+ confidence: float
81
+ keywords: list[str]
82
+
83
+ spec = llm(
84
+ name="analyzer",
85
+ prompt_template="Analyze this text: {{text}}",
86
+ output_schema=Analysis,
87
+ parse_json=True
88
+ )
89
+
90
+ YAML Pipeline Usage::
91
+
92
+ - kind: llm_node
93
+ metadata:
94
+ name: analyzer
95
+ spec:
96
+ prompt_template: "Analyze: {{input}}"
97
+ system_prompt: "You are an analyst"
98
+ parse_json: true
99
+ output_schema:
100
+ summary: str
101
+ confidence: float
102
+ """
103
+
104
+ def __init__(self, **kwargs: Any) -> None:
105
+ """Initialize LLMNode."""
106
+ super().__init__()
107
+
108
+ def __call__(
109
+ self,
110
+ name: str,
111
+ prompt_template: PromptInput | str | None = None,
112
+ output_schema: dict[str, Any] | type[BaseModel] | None = None,
113
+ system_prompt: str | None = None,
114
+ parse_json: bool = False,
115
+ parse_strategy: str = "json",
116
+ deps: list[str] | None = None,
117
+ template: PromptInput | str | None = None, # Alias for prompt_template (YAML compat)
118
+ **kwargs: Any,
119
+ ) -> NodeSpec:
120
+ """Create a unified LLM node specification.
121
+
122
+ Parameters
123
+ ----------
124
+ name : str
125
+ Node name (must be unique in the graph)
126
+ prompt_template : PromptInput | str
127
+ Template for the user prompt. Supports Jinja2-style {{variable}} syntax.
128
+ Can be a string or PromptTemplate/ChatPromptTemplate object.
129
+ Can also be provided as 'template' (alias for YAML compatibility).
130
+ output_schema : dict[str, Any] | type[BaseModel] | None, optional
131
+ Expected output schema for structured output. If provided with parse_json=True,
132
+ the LLM response will be parsed and validated against this schema.
133
+ system_prompt : str | None, optional
134
+ System message to prepend to the conversation.
135
+ parse_json : bool, optional
136
+ If True, parse the LLM response as JSON and validate against output_schema.
137
+ Default is False (returns raw text).
138
+ parse_strategy : str, optional
139
+ JSON parsing strategy: "json", "json_in_markdown", or "yaml".
140
+ Default is "json".
141
+ deps : list[str] | None, optional
142
+ List of dependency node names.
143
+ **kwargs : Any
144
+ Additional parameters passed to NodeSpec.
145
+
146
+ Returns
147
+ -------
148
+ NodeSpec
149
+ Complete node specification ready for execution.
150
+
151
+ Raises
152
+ ------
153
+ ValueError
154
+ If parse_json is True but no output_schema is provided.
155
+
156
+ Examples
157
+ --------
158
+ >>> llm = LLMNode()
159
+ >>> spec = llm(
160
+ ... name="greeter",
161
+ ... prompt_template="Say hello to {{name}}",
162
+ ... system_prompt="You are friendly."
163
+ ... )
164
+ """
165
+ # Handle template alias for YAML compatibility
166
+ actual_template = prompt_template or template
167
+ if actual_template is None:
168
+ raise ValueError("prompt_template (or template) is required")
169
+
170
+ if parse_json and output_schema is None:
171
+ raise ValueError("output_schema is required when parse_json=True")
172
+
173
+ # Prepare template
174
+ prepared_template = self._prepare_template(actual_template)
175
+
176
+ # Infer input schema from template variables
177
+ input_schema = self.infer_input_schema_from_template(
178
+ prepared_template, special_params={"context_history", "system_prompt"}
179
+ )
180
+
181
+ # Create output model if schema provided
182
+ output_model: type[BaseModel] | None = None
183
+ if output_schema is not None:
184
+ output_model = self.create_pydantic_model(f"{name}Output", output_schema)
185
+
186
+ # Create the LLM wrapper function
187
+ llm_wrapper = self._create_llm_wrapper(
188
+ name=name,
189
+ template=prepared_template,
190
+ output_model=output_model,
191
+ system_prompt=system_prompt,
192
+ parse_json=parse_json,
193
+ parse_strategy=parse_strategy,
194
+ )
195
+
196
+ return self.create_node_with_mapping(
197
+ name=name,
198
+ wrapped_fn=llm_wrapper,
199
+ input_schema=input_schema,
200
+ output_schema=output_schema if parse_json else None,
201
+ deps=deps,
202
+ **kwargs,
203
+ )
204
+
205
+ # Template Processing
206
+ # -------------------
207
+
208
+ @staticmethod
209
+ def _prepare_template(template: PromptInput | str) -> TemplateType:
210
+ """Convert string input to PromptTemplate if needed."""
211
+ if isinstance(template, str):
212
+ return PromptTemplate(template)
213
+ return template
214
+
215
+ # LLM Wrapper Creation
216
+ # --------------------
217
+
218
+ def _create_llm_wrapper(
219
+ self,
220
+ name: str,
221
+ template: TemplateType,
222
+ output_model: type[BaseModel] | None,
223
+ system_prompt: str | None,
224
+ parse_json: bool,
225
+ parse_strategy: str,
226
+ ) -> Callable[..., Any]:
227
+ """Create an async LLM wrapper function."""
228
+
229
+ async def llm_wrapper(validated_input: dict[str, Any]) -> Any:
230
+ """Execute LLM call with optional parsing."""
231
+ node_logger = logger.bind(node=name, node_type="llm_node")
232
+ start_time = time.perf_counter()
233
+
234
+ llm = get_port("llm")
235
+ if not llm:
236
+ raise RuntimeError("LLM port not available in execution context")
237
+
238
+ try:
239
+ # Convert input to dict if needed
240
+ try:
241
+ input_dict = to_dict(validated_input)
242
+ except TypeError:
243
+ input_dict = validated_input
244
+
245
+ # Log input variables at debug level
246
+ node_logger.debug(
247
+ "Prompt variables",
248
+ variables=list(input_dict.keys()),
249
+ variable_count=len(input_dict),
250
+ )
251
+
252
+ # Log execution start
253
+ node_logger.info(
254
+ "Calling LLM",
255
+ has_system_prompt=system_prompt is not None,
256
+ parse_json=parse_json,
257
+ parse_strategy=parse_strategy if parse_json else None,
258
+ )
259
+
260
+ # Enhance template with schema instructions if using structured output
261
+ enhanced_template = template
262
+ if parse_json and output_model:
263
+ enhanced_template = self._enhance_template_with_schema(template, output_model)
264
+
265
+ # Generate messages from template
266
+ messages = self._generate_messages(enhanced_template, input_dict, system_prompt)
267
+
268
+ # Call LLM
269
+ response = await llm.aresponse(messages)
270
+
271
+ # Log LLM response received
272
+ duration_ms = (time.perf_counter() - start_time) * 1000
273
+ node_logger.debug(
274
+ "LLM response received",
275
+ response_length=len(response) if isinstance(response, str) else None,
276
+ duration_ms=f"{duration_ms:.2f}",
277
+ )
278
+
279
+ # Parse response if requested
280
+ if parse_json and output_model:
281
+ result = self._parse_response(response, output_model, parse_strategy)
282
+ node_logger.debug(
283
+ "Response parsed",
284
+ output_type=type(result).__name__,
285
+ )
286
+ return result
287
+
288
+ return response
289
+
290
+ except Exception as e:
291
+ duration_ms = (time.perf_counter() - start_time) * 1000
292
+ node_logger.error(
293
+ "LLM call failed",
294
+ duration_ms=f"{duration_ms:.2f}",
295
+ error=str(e),
296
+ error_type=type(e).__name__,
297
+ )
298
+ raise
299
+
300
+ return llm_wrapper
301
+
302
+ def _generate_messages(
303
+ self,
304
+ template: TemplateType,
305
+ input_data: dict[str, Any],
306
+ system_prompt: str | None,
307
+ ) -> list[Message]:
308
+ """Generate messages from template and input data."""
309
+ message_dicts = template.to_messages(**input_data)
310
+
311
+ # Add system prompt if provided and not already present
312
+ if system_prompt:
313
+ has_system = any(msg.get("role") == "system" for msg in message_dicts)
314
+ if not has_system:
315
+ message_dicts.insert(0, {"role": "system", "content": system_prompt})
316
+
317
+ return _convert_dicts_to_messages(message_dicts)
318
+
319
+ # Schema Enhancement
320
+ # ------------------
321
+
322
+ def _enhance_template_with_schema(
323
+ self, template: TemplateType, output_model: type[BaseModel]
324
+ ) -> TemplateType:
325
+ """Add schema instructions to template for structured output."""
326
+ schema_instruction = self._create_schema_instruction(output_model)
327
+ return template + schema_instruction
328
+
329
+ def _create_schema_instruction(self, output_model: type[BaseModel]) -> str:
330
+ """Create schema instruction for structured output."""
331
+ schema = output_model.model_json_schema()
332
+
333
+ fields_info = []
334
+ if "properties" in schema:
335
+ for field_name, field_schema in schema["properties"].items():
336
+ field_type = field_schema.get("type", "any")
337
+ field_desc = field_schema.get("description", "")
338
+ desc_part = f" - {field_desc}" if field_desc else ""
339
+ fields_info.append(f" - {field_name}: {field_type}{desc_part}")
340
+
341
+ fields_text = "\n".join(fields_info) if fields_info else " - (no specific fields defined)"
342
+
343
+ example_data = {field: f"<{field}_value>" for field in schema.get("properties", {})}
344
+ example_json = json.dumps(example_data, indent=2)
345
+
346
+ return f"""
347
+
348
+ ## Output Format
349
+ Respond with valid JSON matching this schema:
350
+ {fields_text}
351
+
352
+ Example: {example_json}
353
+ """
354
+
355
+ # Response Parsing
356
+ # ----------------
357
+
358
+ def _parse_response(
359
+ self, response: str, output_model: type[BaseModel], strategy: str
360
+ ) -> BaseModel:
361
+ """Parse LLM response into structured output."""
362
+ try:
363
+ if strategy == "json":
364
+ parsed_data = self._parse_json(response)
365
+ elif strategy == "json_in_markdown":
366
+ parsed_data = self._parse_json_in_markdown(response)
367
+ elif strategy == "yaml":
368
+ parsed_data = self._parse_yaml(response)
369
+ else:
370
+ parsed_data = self._parse_json(response)
371
+
372
+ except (json.JSONDecodeError, ValueError, SyntaxError) as e:
373
+ error_msg = self._create_parse_error_message(response, str(e), strategy)
374
+ raise ParseError(error_msg) from e
375
+
376
+ # Validate against schema
377
+ try:
378
+ return output_model.model_validate(parsed_data)
379
+ except ValidationError as e:
380
+ error_msg = self._create_validation_error_message(
381
+ response, parsed_data, e, output_model
382
+ )
383
+ raise ParseError(error_msg) from e
384
+
385
+ def _parse_json(self, text: str) -> dict[str, Any]:
386
+ """Parse JSON from text."""
387
+ cleaned = text.strip()
388
+
389
+ try:
390
+ result: dict[str, Any] = json.loads(cleaned)
391
+ return result
392
+ except json.JSONDecodeError:
393
+ # Try to extract JSON from surrounding text
394
+ json_match = re.search(r"(\{.*\}|\[.*\])", cleaned, re.DOTALL)
395
+ if json_match:
396
+ return json.loads(json_match.group(1)) # type: ignore[no-any-return]
397
+ raise
398
+
399
+ def _parse_json_in_markdown(self, text: str) -> dict[str, Any]:
400
+ """Extract and parse JSON from markdown code blocks."""
401
+ code_block_pattern = r"```(?:json)?\s*(.*?)\s*```"
402
+ matches = re.findall(code_block_pattern, text, re.DOTALL)
403
+
404
+ if matches:
405
+ for block in matches:
406
+ try:
407
+ result: dict[str, Any] = json.loads(block)
408
+ return result
409
+ except json.JSONDecodeError:
410
+ continue
411
+
412
+ return self._parse_json(text)
413
+
414
+ def _parse_yaml(self, text: str) -> dict[str, Any]:
415
+ """Parse YAML from text."""
416
+ import yaml
417
+
418
+ result: dict[str, Any] = yaml.safe_load(text)
419
+ return result
420
+
421
+ # Error Messages
422
+ # --------------
423
+
424
+ def _create_parse_error_message(self, text: str, error: str, strategy: str) -> str:
425
+ """Create helpful error message for parse failures."""
426
+ preview = text[:200] + ("..." if len(text) > 200 else "")
427
+
428
+ return f"""
429
+ Failed to parse LLM output using strategy '{strategy}'.
430
+
431
+ Error: {error}
432
+
433
+ Output preview:
434
+ {preview}
435
+
436
+ Retry hints:
437
+ 1. Ensure the LLM output is valid {strategy.upper()} format
438
+ 2. Check for trailing commas, missing quotes, or malformed syntax
439
+ 3. Consider using 'json_in_markdown' strategy if JSON is in code blocks
440
+ """
441
+
442
+ def _create_validation_error_message(
443
+ self,
444
+ text: str,
445
+ parsed_data: Any,
446
+ error: ValidationError,
447
+ model: type[BaseModel],
448
+ ) -> str:
449
+ """Create helpful error message for validation failures."""
450
+ schema = model.model_json_schema()
451
+ required_fields = schema.get("required", [])
452
+
453
+ preview = str(parsed_data)[:200]
454
+
455
+ return f"""
456
+ Parsed data does not match expected schema.
457
+
458
+ Expected schema: {model.__name__}
459
+ Required fields: {required_fields}
460
+
461
+ Parsed data preview:
462
+ {preview}
463
+
464
+ Validation errors:
465
+ {error}
466
+ """
467
+
468
+ # Legacy Compatibility
469
+ # --------------------
470
+
471
+ @classmethod
472
+ def from_template(
473
+ cls,
474
+ name: str,
475
+ template: PromptInput | str,
476
+ output_schema: dict[str, Any] | type[BaseModel] | None = None,
477
+ deps: list[str] | None = None,
478
+ **kwargs: Any,
479
+ ) -> NodeSpec:
480
+ """Create a NodeSpec from template (legacy compatibility method).
481
+
482
+ This method provides backward compatibility with the old LLMNode API.
483
+ """
484
+ return cls()(
485
+ name=name,
486
+ prompt_template=template,
487
+ output_schema=output_schema,
488
+ parse_json=output_schema is not None,
489
+ deps=deps,
490
+ **kwargs,
491
+ )