diff --git a/nodes/__init__.py b/nodes/__init__.py index 0affad0..86e30a4 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -8,6 +8,7 @@ Nodes are thin interfaces; all compiler logic lives in the ``compiler`` package. from __future__ import annotations from .category_splitter_node import CategorySplitterNode +from .configuration_node import ConfigurationNode from .debug_viewer_node import DebugViewerNode from .knowledge_base_loader_node import KnowledgeBaseLoaderNode from .prompt_builder_node import PromptBuilderNode @@ -23,6 +24,7 @@ NODE_CLASS_MAPPINGS: dict[str, type] = { "SceneCompilerPromptBuilder": PromptBuilderNode, "SceneCompilerDebugViewer": DebugViewerNode, "SceneCompilerKnowledgeBaseLoader": KnowledgeBaseLoaderNode, + "SceneCompilerConfiguration": ConfigurationNode, } NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = { @@ -33,6 +35,7 @@ NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = { "SceneCompilerPromptBuilder": "Prompt Builder", "SceneCompilerDebugViewer": "Debug Viewer", "SceneCompilerKnowledgeBaseLoader": "Knowledge Base Loader", + "SceneCompilerConfiguration": "Configuration", } __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/nodes/configuration_node.py b/nodes/configuration_node.py new file mode 100644 index 0000000..32d8efb --- /dev/null +++ b/nodes/configuration_node.py @@ -0,0 +1,108 @@ +"""ComfyUI node for the compiler Configuration (MASTER_SPEC §20, §23). + +A thin interface: it collects the configuration options as node inputs and emits +a single COMPILER_CONFIG that the other nodes consume, so behaviour can change +without editing the workflow. No compiler logic; it never imports ComfyUI. +""" + +from __future__ import annotations + +from typing import Any + +from compiler.common.config import Config, ConfigError + +from .adapters import format_messages + +_DEBUG_LEVELS = ["none", "basic", "verbose", "developer"] + + +class ConfigurationNode: + """Centralizes compiler configuration and emits it for the other nodes.""" + + CATEGORY = "Scene Compiler" + FUNCTION = "run" + RETURN_TYPES = ("COMPILER_CONFIG", "STRING") + RETURN_NAMES = ("config", "errors") + + @classmethod + def INPUT_TYPES(cls) -> dict[str, Any]: + return { + "required": { + "analyzer_model": ("STRING", {"default": "llama3"}), + "analyzer_temperature": ( + "FLOAT", + {"default": 0.0, "min": 0.0, "max": 2.0, "step": 0.1}, + ), + "analyzer_max_retries": ("INT", {"default": 3, "min": 0, "max": 10}), + "analyzer_timeout": ("INT", {"default": 60, "min": 1, "max": 3600}), + "knowledge_base": ("STRING", {"default": "knowledge_base/"}), + "resolver_strict_mode": ("BOOLEAN", {"default": True}), + "resolver_allow_aliases": ("BOOLEAN", {"default": True}), + "resolver_expansion_enabled": ("BOOLEAN", {"default": True}), + "resolver_max_expansion_depth": ("INT", {"default": 8, "min": 1, "max": 32}), + "validator_allow_unknown_fields": ("BOOLEAN", {"default": False}), + "prompt_target": ("STRING", {"default": "easy_illustrious"}), + "prompt_separator": ("STRING", {"default": ","}), + "prompt_trim_empty_outputs": ("BOOLEAN", {"default": True}), + "prompt_remove_duplicate_tags": ("BOOLEAN", {"default": True}), + "debug_enabled": ("BOOLEAN", {"default": False}), + "debug_level": (_DEBUG_LEVELS, {"default": "basic"}), + }, + "optional": { + "analyzer_system_prompt": ("STRING", {"multiline": True, "default": ""}), + }, + } + + def run( + self, + analyzer_model: str, + analyzer_temperature: float, + analyzer_max_retries: int, + analyzer_timeout: int, + knowledge_base: str, + resolver_strict_mode: bool, + resolver_allow_aliases: bool, + resolver_expansion_enabled: bool, + resolver_max_expansion_depth: int, + validator_allow_unknown_fields: bool, + prompt_target: str, + prompt_separator: str, + prompt_trim_empty_outputs: bool, + prompt_remove_duplicate_tags: bool, + debug_enabled: bool, + debug_level: str, + analyzer_system_prompt: str = "", + ) -> tuple[Any, str]: + analyzer: dict[str, Any] = { + "model": analyzer_model, + "temperature": analyzer_temperature, + "max_retries": analyzer_max_retries, + "timeout": analyzer_timeout, + } + if analyzer_system_prompt: + analyzer["system_prompt"] = analyzer_system_prompt + + document = { + "analyzer": analyzer, + "resolver": { + "knowledge_base": knowledge_base, + "strict_mode": resolver_strict_mode, + "allow_aliases": resolver_allow_aliases, + "expansion_enabled": resolver_expansion_enabled, + "max_expansion_depth": resolver_max_expansion_depth, + }, + "validator": {"allow_unknown_fields": validator_allow_unknown_fields}, + "prompt_builder": { + "target": prompt_target, + "separator": prompt_separator, + "trim_empty_outputs": prompt_trim_empty_outputs, + "remove_duplicate_tags": prompt_remove_duplicate_tags, + }, + "debug": {"enabled": debug_enabled, "level": debug_level}, + } + + try: + config = Config.from_json(document) + except ConfigError as error: + return (None, format_messages([error.message])) + return (config, "") diff --git a/tests/test_configuration_node.py b/tests/test_configuration_node.py new file mode 100644 index 0000000..812b0f0 --- /dev/null +++ b/tests/test_configuration_node.py @@ -0,0 +1,86 @@ +"""Tests for the Configuration ComfyUI node (issue #26).""" + +from __future__ import annotations + +import nodes +from compiler.common.config import Config +from nodes.configuration_node import ConfigurationNode + + +def default_inputs() -> dict: + return { + "analyzer_model": "llama3", + "analyzer_temperature": 0.0, + "analyzer_max_retries": 3, + "analyzer_timeout": 60, + "knowledge_base": "knowledge_base/", + "resolver_strict_mode": True, + "resolver_allow_aliases": True, + "resolver_expansion_enabled": True, + "resolver_max_expansion_depth": 8, + "validator_allow_unknown_fields": False, + "prompt_target": "easy_illustrious", + "prompt_separator": ",", + "prompt_trim_empty_outputs": True, + "prompt_remove_duplicate_tags": True, + "debug_enabled": False, + "debug_level": "basic", + } + + +def test_node_metadata() -> None: + assert ConfigurationNode.RETURN_TYPES == ("COMPILER_CONFIG", "STRING") + assert ConfigurationNode.RETURN_NAMES == ("config", "errors") + required = ConfigurationNode.INPUT_TYPES()["required"] + assert "analyzer_model" in required + assert "debug_level" in required + assert "analyzer_system_prompt" in ConfigurationNode.INPUT_TYPES()["optional"] + + +def test_default_inputs_produce_default_config() -> None: + config, errors = ConfigurationNode().run(**default_inputs()) + assert isinstance(config, Config) + assert config == Config() + assert errors == "" + + +def test_inputs_flow_into_config() -> None: + inputs = default_inputs() + inputs["analyzer_model"] = "mistral" + inputs["resolver_max_expansion_depth"] = 4 + inputs["validator_allow_unknown_fields"] = True + inputs["prompt_separator"] = " | " + inputs["debug_enabled"] = True + inputs["debug_level"] = "verbose" + config, _ = ConfigurationNode().run(**inputs) + assert config.analyzer.model == "mistral" + assert config.resolver.max_expansion_depth == 4 + assert config.validator.allow_unknown_fields is True + assert config.prompt_builder.separator == " | " + assert config.debug.enabled is True + assert config.debug.level == "verbose" + + +def test_system_prompt_override_is_applied() -> None: + config, _ = ConfigurationNode().run(**default_inputs(), analyzer_system_prompt="CUSTOM") + assert config.analyzer.system_prompt == "CUSTOM" + + +def test_empty_system_prompt_is_omitted() -> None: + config, _ = ConfigurationNode().run(**default_inputs(), analyzer_system_prompt="") + assert config.analyzer.system_prompt is None + + +def test_debug_level_options_match_config_schema() -> None: + options = ConfigurationNode.INPUT_TYPES()["required"]["debug_level"][0] + for level in options: + inputs = default_inputs() + inputs["debug_level"] = level + config, errors = ConfigurationNode().run(**inputs) + assert isinstance(config, Config) + assert errors == "" + + +def test_registered_for_comfyui() -> None: + assert nodes.NODE_CLASS_MAPPINGS["SceneCompilerConfiguration"] is ConfigurationNode + assert nodes.NODE_DISPLAY_NAME_MAPPINGS["SceneCompilerConfiguration"] == "Configuration"