feat(nodes): centralize duplicated settings on Configuration + add tooltips (#144)

The Scene Analyzer built its own Config from local model_name/temperature/
timeout/system_prompt widgets and ignored the Configuration node entirely, so
the analyzer model was set in two disconnected places (bug report #4).

- Scene Analyzer: add an optional `config` (COMPILER_CONFIG) input and drop the
  duplicated model/temperature/timeout widgets. Settings now come from the
  Configuration node; without one, defaults apply with a 300 s cold-load-friendly
  timeout. A non-empty `system_prompt` widget still overrides the config.
- Knowledge Base Loader: accept an optional `config`; when wired, its KB path and
  version take precedence over the node's own widgets.
- Add tooltips to every input on all six nodes.

Docs (docs/nodes.md) updated for the Analyzer/KB Loader input changes.

Co-authored-by: kunail0804 <kunail0804@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Crespin Alexandre
2026-07-16 16:26:18 +02:00
committed by GitHub
co-authored by kunail0804 Claude Opus 4.8
parent e49c282879
commit 712be0d30a
9 changed files with 362 additions and 69 deletions
+5 -4
View File
@@ -18,7 +18,7 @@ default `knowledge_base/` works regardless of ComfyUI's working directory.
| Node | Inputs | Outputs |
|---|---|---|
| **Scene Analyzer** | `natural_language` (STRING), `model_name` (STRING), `temperature` (FLOAT), `timeout` (INT), `system_prompt` (STRING, optional) | `scene` (SCENE), `warnings` (STRING), `errors` (STRING) |
| **Scene Analyzer** | `natural_language` (STRING), `config` (COMPILER_CONFIG, optional — supplies model/temperature/retries/timeout), `system_prompt` (STRING, optional override) | `scene` (SCENE), `warnings` (STRING), `errors` (STRING) |
| **Scene Validator** | `scene` (SCENE), `config` (COMPILER_CONFIG, optional) | `scene` (SCENE), `warnings` (STRING), `errors` (STRING) |
| **Resolver** | `scene` (SCENE), `knowledge_base` (KNOWLEDGE_BASE), `config` (COMPILER_CONFIG, optional) | `resolved_tags` (RESOLVED_TAGS), `warnings` (STRING), `errors` (STRING) |
| **Category Splitter** | `resolved_tags` (RESOLVED_TAGS) | `category_map` (CATEGORY_MAP), `warnings` (STRING), `errors` (STRING) |
@@ -32,12 +32,13 @@ prompt strings are produced by the **Prompt Builder**.
| Node | Inputs | Outputs |
|---|---|---|
| **Configuration** | one input per configuration option (analyzer, resolver, validator, prompt builder, debug) | `config` (COMPILER_CONFIG), `errors` (STRING) |
| **Knowledge Base Loader** | `path` (STRING), `reload` (INT, optional) | `knowledge_base` (KNOWLEDGE_BASE), `warnings` (STRING), `errors` (STRING) |
| **Knowledge Base Loader** | `path` (STRING), `reload` (INT, optional), `version` (STRING, optional), `config` (COMPILER_CONFIG, optional — its KB path/version win) | `knowledge_base` (KNOWLEDGE_BASE), `warnings` (STRING), `errors` (STRING) |
| **Debug Viewer** | `scene` (SCENE, optional), `resolved_tags` (RESOLVED_TAGS, optional), `category_map` (CATEGORY_MAP, optional), `warnings` (STRING, optional), `errors` (STRING, optional) | `report` (STRING) |
- The **Configuration** node's `config` output feeds the optional `config` input of
the Validator, Resolver, and Prompt Builder, so behaviour changes without
rewiring.
the Scene Analyzer, Validator, Resolver, and Knowledge Base Loader, so behaviour
(including the analyzer model and the Knowledge Base path/version) changes in one
place without rewiring.
- The **Knowledge Base Loader** reloads only when `reload` changes (no automatic
file watching).
- The **Debug Viewer** is read-only; connect any intermediate state to inspect it.
+149 -21
View File
@@ -28,37 +28,165 @@ class ConfigurationNode:
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"analyzer_model": ("STRING", {"default": "llama3"}),
"analyzer_model": (
"STRING",
{
"default": "llama3",
"tooltip": "Ollama model the Scene Analyzer uses to read the description.",
},
),
"analyzer_temperature": (
"FLOAT",
{"default": 0.0, "min": 0.0, "max": 2.0, "step": 0.1},
{
"default": 0.0,
"min": 0.0,
"max": 2.0,
"step": 0.1,
"tooltip": "Analyzer sampling temperature. 0 = most repeatable.",
},
),
"analyzer_max_retries": (
"INT",
{
"default": 3,
"min": 0,
"max": 10,
"tooltip": "How many times to re-ask when the model returns bad JSON.",
},
),
"analyzer_timeout": (
"INT",
{
"default": 300,
"min": 1,
"max": 3600,
"tooltip": (
"Seconds to wait for the model. 300 s is generous so a model that "
"cold-loads into VRAM on the first call does not time out."
),
},
),
"knowledge_base": (
"STRING",
{
"default": "knowledge_base/",
"tooltip": (
"Knowledge Base directory. Relative paths resolve against the node "
"package. This is the authoritative path when this Configuration is "
"wired into the Knowledge Base Loader."
),
},
),
"resolver_strict_mode": (
"BOOLEAN",
{"default": True, "tooltip": "Report unknown concepts instead of guessing."},
),
"resolver_allow_aliases": (
"BOOLEAN",
{"default": True, "tooltip": "Resolve aliases to their canonical entry."},
),
"resolver_expansion_enabled": (
"BOOLEAN",
{"default": True, "tooltip": "Auto-add tags from an entry's expansion list."},
),
"resolver_max_expansion_depth": (
"INT",
{
"default": 8,
"min": 1,
"max": 32,
"tooltip": "How deep expansion may recurse before stopping.",
},
),
"resolver_include_nsfw": (
"BOOLEAN",
{
"default": False,
"tooltip": "Include explicit-rated Knowledge Base entries. Off = SFW only.",
},
),
"validator_allow_unknown_fields": (
"BOOLEAN",
{
"default": False,
"tooltip": "Keep unrecognized Scene JSON fields instead of stripping them.",
},
),
"prompt_target": (
"STRING",
{
"default": "easy_illustrious",
"tooltip": "Reserved prompt-format label. Currently informational.",
},
),
"prompt_separator": (
"STRING",
{
"default": ",",
"tooltip": "String used to join the resolved tags into the final prompt.",
},
),
"prompt_remove_duplicate_tags": (
"BOOLEAN",
{
"default": True,
"tooltip": "Drop duplicate tags after expansion (warns SC0007).",
},
),
"debug_enabled": (
"BOOLEAN",
{"default": False, "tooltip": "Enable extra diagnostic logging."},
),
"debug_level": (
_DEBUG_LEVELS,
{"default": "basic", "tooltip": "Verbosity of debug logging when enabled."},
),
"analyzer_max_retries": ("INT", {"default": 3, "min": 0, "max": 10}),
"analyzer_timeout": ("INT", {"default": 300, "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}),
"resolver_include_nsfw": ("BOOLEAN", {"default": False}),
"validator_allow_unknown_fields": ("BOOLEAN", {"default": False}),
"prompt_target": ("STRING", {"default": "easy_illustrious"}),
"prompt_separator": ("STRING", {"default": ","}),
"prompt_remove_duplicate_tags": ("BOOLEAN", {"default": True}),
"debug_enabled": ("BOOLEAN", {"default": False}),
"debug_level": (_DEBUG_LEVELS, {"default": "basic"}),
},
# Appended at the END so saved workflows keep their positional widget
# values (ComfyUI stores widget values positionally).
"optional": {
"analyzer_system_prompt": ("STRING", {"multiline": True, "default": ""}),
"resolver_knowledge_base_version": ("STRING", {"default": ""}),
"semantic_enabled": ("BOOLEAN", {"default": False}),
"analyzer_system_prompt": (
"STRING",
{
"multiline": True,
"default": "",
"tooltip": "Advanced: override the analyzer system prompt.",
},
),
"resolver_knowledge_base_version": (
"STRING",
{
"default": "",
"tooltip": "Pin a KB dataset version. Empty = unpinned.",
},
),
"semantic_enabled": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Opt-in nearest-neighbour fallback for concepts that miss "
"deterministic lookup. Off by default; deterministic lookup wins."
),
},
),
"semantic_min_similarity": (
"FLOAT",
{"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05},
{
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Minimum similarity to accept a semantic-fallback match.",
},
),
"semantic_backend": (
"STRING",
{
"default": "char_ngram",
"tooltip": "Embedding backend for the semantic fallback (offline).",
},
),
"semantic_backend": ("STRING", {"default": "char_ngram"}),
},
}
+12 -3
View File
@@ -26,9 +26,18 @@ class DebugViewerNode:
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"optional": {
"scene": ("SCENE",),
"warnings": ("STRING", {"default": "", "forceInput": True}),
"errors": ("STRING", {"default": "", "forceInput": True}),
"scene": (
"SCENE",
{"tooltip": "Scene JSON to inspect (Analyzer/Validator output)."},
),
"warnings": (
"STRING",
{"default": "", "forceInput": True, "tooltip": "A stage's warnings output."},
),
"errors": (
"STRING",
{"default": "", "forceInput": True, "tooltip": "A stage's errors output."},
),
}
}
+54 -4
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
from compiler.common.config import Config
from compiler.common.knowledge_base import (
KnowledgeBaseError,
KnowledgeBaseLoader,
@@ -49,16 +50,65 @@ class KnowledgeBaseLoaderNode:
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {"path": ("STRING", {"default": "knowledge_base/"})},
"required": {
"path": (
"STRING",
{
"default": "knowledge_base/",
"tooltip": (
"Knowledge Base directory. Relative paths resolve against the "
"node package, so the default works from any ComfyUI launch "
"directory. Overridden by a connected Configuration node."
),
},
)
},
# New widgets are appended at the END so saved workflows keep their
# positional widget values (ComfyUI stores widgets positionally).
"optional": {
"reload": ("INT", {"default": 0, "min": 0}),
"version": ("STRING", {"default": ""}),
"reload": (
"INT",
{
"default": 0,
"min": 0,
"tooltip": "Bump to force a fresh read of the Knowledge Base from disk.",
},
),
"version": (
"STRING",
{
"default": "",
"tooltip": (
"Optional Knowledge Base dataset version to pin (matched against "
"the dataset manifest). Empty means use whatever is on the path. "
"Overridden by a connected Configuration node."
),
},
),
"config": (
"COMPILER_CONFIG",
{
"tooltip": (
"Optional Configuration node. When connected, its Knowledge Base "
"path and version take precedence over the widgets above."
)
},
),
},
}
def run(self, path: str, reload: int = 0, version: str = "") -> tuple[Any, str, str, str]:
def run(
self,
path: str,
reload: int = 0,
version: str = "",
config: Config | None = None,
) -> tuple[Any, str, str, str]:
# The Configuration node is the single source of truth when wired: its
# Knowledge Base path/version win over this node's own widgets.
if config is not None:
path = config.resolver.knowledge_base
version = config.resolver.knowledge_base_version or ""
resolved = _resolve_path(path)
requested_version = version or None
key = (str(resolved), version)
+16 -3
View File
@@ -30,10 +30,23 @@ class ResolverNode:
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"scene": ("SCENE",),
"knowledge_base": ("KNOWLEDGE_BASE",),
"scene": ("SCENE", {"tooltip": "Validated Scene JSON from the Scene Validator."}),
"knowledge_base": (
"KNOWLEDGE_BASE",
{"tooltip": "Loaded Knowledge Base from the Knowledge Base Loader."},
),
},
"optional": {
"config": (
"COMPILER_CONFIG",
{
"tooltip": (
"Optional Configuration node. Supplies resolver options, the prompt "
"separator, duplicate handling and the opt-in semantic fallback."
)
},
)
},
"optional": {"config": ("COMPILER_CONFIG",)},
}
def run(
+62 -18
View File
@@ -3,6 +3,13 @@
A thin interface: it adapts ComfyUI inputs to the Analyzer module and surfaces the
CompilerResult's data, warnings, and errors. It contains no compiler logic and
never imports ComfyUI.
Analyzer settings (model, temperature, retries, timeout, system prompt) live on
the **Configuration** node and reach this node through the optional ``config``
input, so a setting like the model name is entered in exactly one place. Without a
Configuration node the built-in defaults apply (llama3, temperature 0), with a
generous 300 s timeout so a local Ollama model that cold-loads into VRAM on the
first call does not time out.
"""
from __future__ import annotations
@@ -15,6 +22,10 @@ from compiler.common.config import Config
from .adapters import format_messages
# Cold-load-friendly fallback timeout used only when no Configuration node is
# wired; a local model loading into VRAM on the first call can take minutes.
_DEFAULT_TIMEOUT = 300
class SceneAnalyzerNode:
"""Runs the Scene Analyzer and emits Scene plus warnings/errors."""
@@ -28,33 +39,47 @@ class SceneAnalyzerNode:
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"natural_language": ("STRING", {"multiline": True, "default": ""}),
"model_name": ("STRING", {"default": "llama3"}),
"temperature": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 2.0, "step": 0.1}),
"timeout": ("INT", {"default": 300, "min": 1, "max": 3600}),
"natural_language": (
"STRING",
{
"multiline": True,
"default": "",
"tooltip": "The scene description to analyze, in plain language.",
},
),
},
"optional": {
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
"config": (
"COMPILER_CONFIG",
{
"tooltip": (
"Optional Configuration node. Supplies the analyzer model, "
"temperature, retries and timeout. If left unconnected, the "
"defaults apply (llama3, temperature 0, 300 s timeout)."
)
},
),
"system_prompt": (
"STRING",
{
"multiline": True,
"default": "",
"tooltip": (
"Advanced: override the analyzer system prompt. Leave empty to "
"use the built-in prompt (or the one from the Configuration node)."
),
},
),
},
}
def run(
self,
natural_language: str,
model_name: str,
temperature: float,
timeout: int,
config: Config | None = None,
system_prompt: str = "",
) -> tuple[Any, str, str]:
analyzer: dict[str, Any] = {
"model": model_name,
"temperature": temperature,
"timeout": timeout,
}
if system_prompt:
analyzer["system_prompt"] = system_prompt
config = Config.from_json({"analyzer": analyzer})
) -> tuple[Any, str, str, str]:
config = self._resolve_config(config, system_prompt)
backend = OllamaBackend.from_config(config)
result = analyze(natural_language, backend, config)
return (
@@ -66,3 +91,22 @@ class SceneAnalyzerNode:
# re-serialization of the parsed Scene.
result.metadata.get("raw_response", ""),
)
@staticmethod
def _resolve_config(config: Config | None, system_prompt: str) -> Config:
"""Pick the effective config, applying the optional system-prompt override.
No Configuration node: build a default config with a cold-load-friendly
timeout. A non-empty ``system_prompt`` widget always wins over whatever the
Configuration node carries.
"""
if config is None:
analyzer: dict[str, Any] = {"timeout": _DEFAULT_TIMEOUT}
if system_prompt:
analyzer["system_prompt"] = system_prompt
return Config.from_json({"analyzer": analyzer})
if system_prompt:
document = config.to_json()
document.setdefault("analyzer", {})["system_prompt"] = system_prompt
return Config.from_json(document)
return config
+9 -2
View File
@@ -26,8 +26,15 @@ class SceneValidatorNode:
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {"scene": ("SCENE",)},
"optional": {"config": ("COMPILER_CONFIG",)},
"required": {
"scene": ("SCENE", {"tooltip": "Raw Scene JSON from the Scene Analyzer."}),
},
"optional": {
"config": (
"COMPILER_CONFIG",
{"tooltip": "Optional Configuration node; supplies the unknown-field policy."},
)
},
}
def run(self, scene: Any, config: Config | None = None) -> tuple[Any, str, str, str]:
+14
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import nodes
from compiler.common.config import Config
from compiler.common.knowledge_base import KnowledgeBase
from nodes.knowledge_base_loader_node import KnowledgeBaseLoaderNode
@@ -60,6 +61,19 @@ def test_reload_input_is_accepted(tmp_path) -> None:
assert isinstance(kb, KnowledgeBase)
def test_config_path_overrides_the_widget(tmp_path) -> None:
# A connected Configuration node is the single source of truth: its Knowledge
# Base path wins over the node's own ``path`` widget.
config_path = write_kb(
tmp_path, [{"id": "long_hair", "tags": ["long hair"], "category": "hair"}]
)
config = Config.from_json({"resolver": {"knowledge_base": config_path}})
kb, warnings, errors, *_ = KnowledgeBaseLoaderNode().run("does/not/exist", config=config)
assert isinstance(kb, KnowledgeBase)
assert kb.get("long_hair") is not None
assert (warnings, errors) == ("", "")
def test_registered_for_comfyui() -> None:
assert nodes.NODE_CLASS_MAPPINGS["SceneCompilerKnowledgeBaseLoader"] is KnowledgeBaseLoaderNode
assert (
+41 -14
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import nodes
import nodes.scene_analyzer_node as node_module
from compiler.common.config import Config
from compiler.common.message_codes import message
from compiler.common.result import CompilerResult
from nodes.scene_analyzer_node import SceneAnalyzerNode
@@ -11,13 +12,10 @@ from nodes.scene_analyzer_node import SceneAnalyzerNode
def test_input_types_structure() -> None:
inputs = SceneAnalyzerNode.INPUT_TYPES()
assert set(inputs["required"]) == {
"natural_language",
"model_name",
"temperature",
"timeout",
}
assert "system_prompt" in inputs["optional"]
# Analyzer settings now live on the Configuration node; only the per-run text
# and the optional config/override remain.
assert set(inputs["required"]) == {"natural_language"}
assert set(inputs["optional"]) == {"config", "system_prompt"}
def test_node_metadata() -> None:
@@ -27,7 +25,7 @@ def test_node_metadata() -> None:
assert SceneAnalyzerNode.CATEGORY == "Scene Compiler"
def test_run_delegates_to_analyzer_and_surfaces_messages(monkeypatch) -> None:
def test_run_uses_model_from_config(monkeypatch) -> None:
captured = {}
def fake_analyze(description, backend, config):
@@ -41,11 +39,10 @@ def test_run_delegates_to_analyzer_and_surfaces_messages(monkeypatch) -> None:
monkeypatch.setattr(node_module, "analyze", fake_analyze)
config = Config.from_json({"analyzer": {"model": "mistral"}})
scene, warnings, errors, *_ = SceneAnalyzerNode().run(
natural_language="A girl.",
model_name="mistral",
temperature=0.0,
timeout=60,
config=config,
)
assert scene == "SCENE_MODEL"
assert captured["description"] == "A girl."
@@ -54,13 +51,28 @@ def test_run_delegates_to_analyzer_and_surfaces_messages(monkeypatch) -> None:
assert "SC0011: bad response" in errors
def test_defaults_apply_without_a_configuration_node(monkeypatch) -> None:
captured = {}
def fake_analyze(description, backend, config):
captured["model"] = config.analyzer.model
captured["timeout"] = config.analyzer.timeout
return CompilerResult(data="S")
monkeypatch.setattr(node_module, "analyze", fake_analyze)
SceneAnalyzerNode().run("A girl.")
# Falls back to the built-in model with a cold-load-friendly timeout.
assert captured["model"] == "llama3"
assert captured["timeout"] == 300
def test_no_messages_yield_empty_strings(monkeypatch) -> None:
monkeypatch.setattr(node_module, "analyze", lambda d, b, c: CompilerResult(data="S"))
scene, warnings, errors, *_ = SceneAnalyzerNode().run("x", "llama3", 0.0, 60)
scene, warnings, errors, *_ = SceneAnalyzerNode().run("x")
assert (warnings, errors) == ("", "")
def test_system_prompt_override_is_passed(monkeypatch) -> None:
def test_system_prompt_override_without_config(monkeypatch) -> None:
captured = {}
def fake_analyze(description, backend, config):
@@ -68,10 +80,25 @@ def test_system_prompt_override_is_passed(monkeypatch) -> None:
return CompilerResult(data="S")
monkeypatch.setattr(node_module, "analyze", fake_analyze)
SceneAnalyzerNode().run("x", "llama3", 0.0, 60, system_prompt="CUSTOM")
SceneAnalyzerNode().run("x", system_prompt="CUSTOM")
assert captured["system_prompt"] == "CUSTOM"
def test_system_prompt_override_wins_over_config(monkeypatch) -> None:
captured = {}
def fake_analyze(description, backend, config):
captured["system_prompt"] = config.analyzer.system_prompt
captured["model"] = config.analyzer.model
return CompilerResult(data="S")
monkeypatch.setattr(node_module, "analyze", fake_analyze)
config = Config.from_json({"analyzer": {"model": "mistral", "system_prompt": "FROM_CONFIG"}})
SceneAnalyzerNode().run("x", config=config, system_prompt="OVERRIDE")
assert captured["system_prompt"] == "OVERRIDE"
assert captured["model"] == "mistral"
def test_registered_for_comfyui() -> None:
assert nodes.NODE_CLASS_MAPPINGS["SceneCompilerAnalyzer"] is SceneAnalyzerNode
assert nodes.NODE_DISPLAY_NAME_MAPPINGS["SceneCompilerAnalyzer"] == "Scene Analyzer"