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.
- hexdag/__init__.py +116 -0
- hexdag/__main__.py +30 -0
- hexdag/adapters/executors/__init__.py +5 -0
- hexdag/adapters/executors/local_executor.py +316 -0
- hexdag/builtin/__init__.py +6 -0
- hexdag/builtin/adapters/__init__.py +51 -0
- hexdag/builtin/adapters/anthropic/__init__.py +5 -0
- hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
- hexdag/builtin/adapters/database/__init__.py +6 -0
- hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
- hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
- hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
- hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
- hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
- hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
- hexdag/builtin/adapters/local/README.md +59 -0
- hexdag/builtin/adapters/local/__init__.py +7 -0
- hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
- hexdag/builtin/adapters/memory/__init__.py +47 -0
- hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
- hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
- hexdag/builtin/adapters/memory/schemas.py +57 -0
- hexdag/builtin/adapters/memory/session_memory.py +178 -0
- hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
- hexdag/builtin/adapters/memory/state_memory.py +280 -0
- hexdag/builtin/adapters/mock/README.md +89 -0
- hexdag/builtin/adapters/mock/__init__.py +15 -0
- hexdag/builtin/adapters/mock/hexdag.toml +50 -0
- hexdag/builtin/adapters/mock/mock_database.py +225 -0
- hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
- hexdag/builtin/adapters/mock/mock_llm.py +177 -0
- hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
- hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
- hexdag/builtin/adapters/openai/__init__.py +5 -0
- hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
- hexdag/builtin/adapters/secret/__init__.py +7 -0
- hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
- hexdag/builtin/adapters/unified_tool_router.py +280 -0
- hexdag/builtin/macros/__init__.py +17 -0
- hexdag/builtin/macros/conversation_agent.py +390 -0
- hexdag/builtin/macros/llm_macro.py +151 -0
- hexdag/builtin/macros/reasoning_agent.py +423 -0
- hexdag/builtin/macros/tool_macro.py +380 -0
- hexdag/builtin/nodes/__init__.py +38 -0
- hexdag/builtin/nodes/_discovery.py +123 -0
- hexdag/builtin/nodes/agent_node.py +696 -0
- hexdag/builtin/nodes/base_node_factory.py +242 -0
- hexdag/builtin/nodes/composite_node.py +926 -0
- hexdag/builtin/nodes/data_node.py +201 -0
- hexdag/builtin/nodes/expression_node.py +487 -0
- hexdag/builtin/nodes/function_node.py +454 -0
- hexdag/builtin/nodes/llm_node.py +491 -0
- hexdag/builtin/nodes/loop_node.py +920 -0
- hexdag/builtin/nodes/mapped_input.py +518 -0
- hexdag/builtin/nodes/port_call_node.py +269 -0
- hexdag/builtin/nodes/tool_call_node.py +195 -0
- hexdag/builtin/nodes/tool_utils.py +390 -0
- hexdag/builtin/prompts/__init__.py +68 -0
- hexdag/builtin/prompts/base.py +422 -0
- hexdag/builtin/prompts/chat_prompts.py +303 -0
- hexdag/builtin/prompts/error_correction_prompts.py +320 -0
- hexdag/builtin/prompts/tool_prompts.py +160 -0
- hexdag/builtin/tools/builtin_tools.py +84 -0
- hexdag/builtin/tools/database_tools.py +164 -0
- hexdag/cli/__init__.py +17 -0
- hexdag/cli/__main__.py +7 -0
- hexdag/cli/commands/__init__.py +27 -0
- hexdag/cli/commands/build_cmd.py +812 -0
- hexdag/cli/commands/create_cmd.py +208 -0
- hexdag/cli/commands/docs_cmd.py +293 -0
- hexdag/cli/commands/generate_types_cmd.py +252 -0
- hexdag/cli/commands/init_cmd.py +188 -0
- hexdag/cli/commands/pipeline_cmd.py +494 -0
- hexdag/cli/commands/plugin_dev_cmd.py +529 -0
- hexdag/cli/commands/plugins_cmd.py +441 -0
- hexdag/cli/commands/studio_cmd.py +101 -0
- hexdag/cli/commands/validate_cmd.py +221 -0
- hexdag/cli/main.py +84 -0
- hexdag/core/__init__.py +83 -0
- hexdag/core/config/__init__.py +20 -0
- hexdag/core/config/loader.py +479 -0
- hexdag/core/config/models.py +150 -0
- hexdag/core/configurable.py +294 -0
- hexdag/core/context/__init__.py +37 -0
- hexdag/core/context/execution_context.py +378 -0
- hexdag/core/docs/__init__.py +26 -0
- hexdag/core/docs/extractors.py +678 -0
- hexdag/core/docs/generators.py +890 -0
- hexdag/core/docs/models.py +120 -0
- hexdag/core/domain/__init__.py +10 -0
- hexdag/core/domain/dag.py +1225 -0
- hexdag/core/exceptions.py +234 -0
- hexdag/core/expression_parser.py +569 -0
- hexdag/core/logging.py +449 -0
- hexdag/core/models/__init__.py +17 -0
- hexdag/core/models/base.py +138 -0
- hexdag/core/orchestration/__init__.py +46 -0
- hexdag/core/orchestration/body_executor.py +481 -0
- hexdag/core/orchestration/components/__init__.py +97 -0
- hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
- hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
- hexdag/core/orchestration/components/execution_coordinator.py +360 -0
- hexdag/core/orchestration/components/health_check_manager.py +176 -0
- hexdag/core/orchestration/components/input_mapper.py +143 -0
- hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
- hexdag/core/orchestration/components/node_executor.py +377 -0
- hexdag/core/orchestration/components/secret_manager.py +202 -0
- hexdag/core/orchestration/components/wave_executor.py +158 -0
- hexdag/core/orchestration/constants.py +17 -0
- hexdag/core/orchestration/events/README.md +312 -0
- hexdag/core/orchestration/events/__init__.py +104 -0
- hexdag/core/orchestration/events/batching.py +330 -0
- hexdag/core/orchestration/events/decorators.py +139 -0
- hexdag/core/orchestration/events/events.py +573 -0
- hexdag/core/orchestration/events/observers/__init__.py +30 -0
- hexdag/core/orchestration/events/observers/core_observers.py +690 -0
- hexdag/core/orchestration/events/observers/models.py +111 -0
- hexdag/core/orchestration/events/taxonomy.py +269 -0
- hexdag/core/orchestration/hook_context.py +237 -0
- hexdag/core/orchestration/hooks.py +437 -0
- hexdag/core/orchestration/models.py +418 -0
- hexdag/core/orchestration/orchestrator.py +910 -0
- hexdag/core/orchestration/orchestrator_factory.py +275 -0
- hexdag/core/orchestration/port_wrappers.py +327 -0
- hexdag/core/orchestration/prompt/__init__.py +32 -0
- hexdag/core/orchestration/prompt/template.py +332 -0
- hexdag/core/pipeline_builder/__init__.py +21 -0
- hexdag/core/pipeline_builder/component_instantiator.py +386 -0
- hexdag/core/pipeline_builder/include_tag.py +265 -0
- hexdag/core/pipeline_builder/pipeline_config.py +133 -0
- hexdag/core/pipeline_builder/py_tag.py +223 -0
- hexdag/core/pipeline_builder/tag_discovery.py +268 -0
- hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
- hexdag/core/pipeline_builder/yaml_validator.py +569 -0
- hexdag/core/ports/__init__.py +65 -0
- hexdag/core/ports/api_call.py +133 -0
- hexdag/core/ports/database.py +489 -0
- hexdag/core/ports/embedding.py +215 -0
- hexdag/core/ports/executor.py +237 -0
- hexdag/core/ports/file_storage.py +117 -0
- hexdag/core/ports/healthcheck.py +87 -0
- hexdag/core/ports/llm.py +551 -0
- hexdag/core/ports/memory.py +70 -0
- hexdag/core/ports/observer_manager.py +130 -0
- hexdag/core/ports/secret.py +145 -0
- hexdag/core/ports/tool_router.py +94 -0
- hexdag/core/ports_builder.py +623 -0
- hexdag/core/protocols.py +273 -0
- hexdag/core/resolver.py +304 -0
- hexdag/core/schema/__init__.py +9 -0
- hexdag/core/schema/generator.py +742 -0
- hexdag/core/secrets.py +242 -0
- hexdag/core/types.py +413 -0
- hexdag/core/utils/async_warnings.py +206 -0
- hexdag/core/utils/schema_conversion.py +78 -0
- hexdag/core/utils/sql_validation.py +86 -0
- hexdag/core/validation/secure_json.py +148 -0
- hexdag/core/yaml_macro.py +517 -0
- hexdag/mcp_server.py +3120 -0
- hexdag/studio/__init__.py +10 -0
- hexdag/studio/build_ui.py +92 -0
- hexdag/studio/server/__init__.py +1 -0
- hexdag/studio/server/main.py +100 -0
- hexdag/studio/server/routes/__init__.py +9 -0
- hexdag/studio/server/routes/execute.py +208 -0
- hexdag/studio/server/routes/export.py +558 -0
- hexdag/studio/server/routes/files.py +207 -0
- hexdag/studio/server/routes/plugins.py +419 -0
- hexdag/studio/server/routes/validate.py +220 -0
- hexdag/studio/ui/index.html +13 -0
- hexdag/studio/ui/package-lock.json +2992 -0
- hexdag/studio/ui/package.json +31 -0
- hexdag/studio/ui/postcss.config.js +6 -0
- hexdag/studio/ui/public/hexdag.svg +5 -0
- hexdag/studio/ui/src/App.tsx +251 -0
- hexdag/studio/ui/src/components/Canvas.tsx +408 -0
- hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
- hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
- hexdag/studio/ui/src/components/Header.tsx +181 -0
- hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
- hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
- hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
- hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
- hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
- hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
- hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
- hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
- hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
- hexdag/studio/ui/src/components/index.ts +8 -0
- hexdag/studio/ui/src/index.css +92 -0
- hexdag/studio/ui/src/main.tsx +10 -0
- hexdag/studio/ui/src/types/index.ts +123 -0
- hexdag/studio/ui/src/vite-env.d.ts +1 -0
- hexdag/studio/ui/tailwind.config.js +29 -0
- hexdag/studio/ui/tsconfig.json +37 -0
- hexdag/studio/ui/tsconfig.node.json +13 -0
- hexdag/studio/ui/vite.config.ts +35 -0
- hexdag/visualization/__init__.py +69 -0
- hexdag/visualization/dag_visualizer.py +1020 -0
- hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
- hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
- hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
- hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
- hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
- hexdag_plugins/.gitignore +43 -0
- hexdag_plugins/README.md +73 -0
- hexdag_plugins/__init__.py +1 -0
- hexdag_plugins/azure/LICENSE +21 -0
- hexdag_plugins/azure/README.md +414 -0
- hexdag_plugins/azure/__init__.py +21 -0
- hexdag_plugins/azure/azure_blob_adapter.py +450 -0
- hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
- hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
- hexdag_plugins/azure/azure_openai_adapter.py +415 -0
- hexdag_plugins/azure/pyproject.toml +107 -0
- hexdag_plugins/azure/tests/__init__.py +1 -0
- hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
- hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
- hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
- hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
- hexdag_plugins/hexdag_etl/README.md +168 -0
- hexdag_plugins/hexdag_etl/__init__.py +53 -0
- hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
- hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
- hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
- hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
- hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
- hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
- hexdag_plugins/hexdag_etl/test_transform.py +54 -0
- hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
- hexdag_plugins/mysql_adapter/LICENSE +21 -0
- hexdag_plugins/mysql_adapter/README.md +224 -0
- hexdag_plugins/mysql_adapter/__init__.py +6 -0
- hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
- hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
- hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
- hexdag_plugins/storage/README.md +184 -0
- hexdag_plugins/storage/__init__.py +19 -0
- hexdag_plugins/storage/file/__init__.py +5 -0
- hexdag_plugins/storage/file/local.py +325 -0
- hexdag_plugins/storage/ports/__init__.py +5 -0
- hexdag_plugins/storage/ports/vector_store.py +236 -0
- hexdag_plugins/storage/sql/__init__.py +7 -0
- hexdag_plugins/storage/sql/base.py +187 -0
- hexdag_plugins/storage/sql/mysql.py +27 -0
- hexdag_plugins/storage/sql/postgresql.py +27 -0
- hexdag_plugins/storage/tests/__init__.py +1 -0
- hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
- hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
- hexdag_plugins/storage/vector/__init__.py +7 -0
- hexdag_plugins/storage/vector/chromadb.py +223 -0
- hexdag_plugins/storage/vector/in_memory.py +285 -0
- 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
|
+
)
|