fix(nodes): resolve KB path against the package and add raw debug outputs (#78)

Two ComfyUI issues found while testing.

1. Empty output with no error: the Knowledge Base Loader resolved a relative
   `path` against ComfyUI's working directory, not this package, so the default
   `knowledge_base/` loaded 0 entries (an empty KB is valid) and every concept
   became unknown -> empty prompts. Relative paths now resolve against the
   package root, and the loader warns when the Knowledge Base is empty.

2. Add a `raw` string output (a JSON dump of the node's data) to the Scene
   Analyzer, Scene Validator, Resolver, Category Splitter, and Prompt Builder
   nodes, plus a summary `raw` on the Knowledge Base Loader, for inspecting the
   pipeline in the graph. Adds a shared to_raw_json helper.

Tests: package-relative path resolution (independent of cwd), empty-KB warning,
raw output content, and updated node metadata/return-arity expectations.

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-09 18:54:40 +02:00
committed by GitHub
co-authored by kunail0804 Claude Opus 4.8
parent c967327e8f
commit de5edd7064
15 changed files with 160 additions and 63 deletions
+6
View File
@@ -8,6 +8,12 @@ as typed objects; the inter-stage types are `SCENE`, `RESOLVED_TAGS`,
Nodes are thin adapters: all behaviour lives in the `compiler` package, which
never imports ComfyUI.
Every stage node also exposes a **`raw`** string output — a JSON dump of that
node's data output — for inspecting the pipeline (wire it into a text/preview
node). The Knowledge Base Loader's `raw` summarizes the loaded entries. A
relative Knowledge Base `path` is resolved against the installed package, so the
default `knowledge_base/` works regardless of ComfyUI's working directory.
## Pipeline nodes
| Node | Inputs | Outputs |
+20
View File
@@ -21,6 +21,26 @@ def format_messages(messages: Iterable[Message]) -> str:
return "\n".join(f"{message.code}: {message.description}" for message in messages)
def to_raw_json(data: Any) -> str:
"""Serialize a stage's data output to a pretty JSON string for inspection.
Handles models (via their ``to_json``), tuples/lists of models, plain values,
and ``None`` (returns an empty string). Used for the debug ``raw`` outputs.
"""
if data is None:
return ""
try:
if hasattr(data, "to_json"):
payload: Any = data.to_json()
elif isinstance(data, list | tuple):
payload = [item.to_json() if hasattr(item, "to_json") else item for item in data]
else:
payload = data
return json.dumps(payload, indent=2, ensure_ascii=False)
except (TypeError, ValueError):
return repr(data)
def upstream_failure_message(what: str) -> str:
"""A stage's error string when a required input is missing (upstream failed)."""
return (
+11 -6
View File
@@ -15,7 +15,7 @@ from typing import Any
from compiler.splitter.category_splitter import split_into_categories
from .adapters import format_messages, upstream_failure_message
from .adapters import format_messages, to_raw_json, upstream_failure_message
class CategorySplitterNode:
@@ -23,15 +23,20 @@ class CategorySplitterNode:
CATEGORY = "Scene Compiler"
FUNCTION = "run"
RETURN_TYPES = ("CATEGORY_MAP", "STRING", "STRING")
RETURN_NAMES = ("category_map", "warnings", "errors")
RETURN_TYPES = ("CATEGORY_MAP", "STRING", "STRING", "STRING")
RETURN_NAMES = ("category_map", "warnings", "errors", "raw")
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {"required": {"resolved_tags": ("RESOLVED_TAGS",)}}
def run(self, resolved_tags: Any) -> tuple[Any, str, str]:
def run(self, resolved_tags: Any) -> tuple[Any, str, str, str]:
if resolved_tags is None:
return (None, "", upstream_failure_message("resolved tags"))
return (None, "", upstream_failure_message("resolved tags"), "")
result = split_into_categories(tuple(resolved_tags))
return (result.data, format_messages(result.warnings), format_messages(result.errors))
return (
result.data,
format_messages(result.warnings),
format_messages(result.errors),
to_raw_json(result.data),
)
+32 -8
View File
@@ -3,26 +3,38 @@
A thin interface: it loads a Knowledge Base directory via the loader module and
exposes it for the Resolver node. No compiler logic; it never imports ComfyUI.
Reloading is explicit: bumping the ``reload`` input changes the node's inputs so
ComfyUI re-executes it (there is no automatic file watching).
A relative ``path`` is resolved against this package (where the shipped
``knowledge_base/`` lives), not ComfyUI's working directory, so the default works
regardless of where ComfyUI is launched from. Reloading is explicit: bumping the
``reload`` input re-executes the node (there is no automatic file watching).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from compiler.common.knowledge_base import KnowledgeBaseError, load_knowledge_base
from .adapters import format_messages
# Package root: the repository directory that ships knowledge_base/, prompts/, ...
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent
def _resolve_path(path: str) -> Path:
"""Resolve a relative Knowledge Base path against the package root."""
candidate = Path(path)
return candidate if candidate.is_absolute() else _PACKAGE_ROOT / candidate
class KnowledgeBaseLoaderNode:
"""Loads the Knowledge Base and exposes it for the Resolver."""
CATEGORY = "Scene Compiler"
FUNCTION = "run"
RETURN_TYPES = ("KNOWLEDGE_BASE", "STRING", "STRING")
RETURN_NAMES = ("knowledge_base", "warnings", "errors")
RETURN_TYPES = ("KNOWLEDGE_BASE", "STRING", "STRING", "STRING")
RETURN_NAMES = ("knowledge_base", "warnings", "errors", "raw")
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
@@ -31,9 +43,21 @@ class KnowledgeBaseLoaderNode:
"optional": {"reload": ("INT", {"default": 0, "min": 0})},
}
def run(self, path: str, reload: int = 0) -> tuple[Any, str, str]:
def run(self, path: str, reload: int = 0) -> tuple[Any, str, str, str]:
resolved = _resolve_path(path)
try:
knowledge_base = load_knowledge_base(path)
knowledge_base = load_knowledge_base(resolved)
except KnowledgeBaseError as error:
return (None, "", format_messages([error.message, *error.findings]))
return (knowledge_base, "", "")
errors = format_messages([error.message, *error.findings])
return (None, "", errors, f"Failed to load Knowledge Base from: {resolved}")
count = len(knowledge_base)
warnings = ""
if count == 0:
warnings = (
f"Knowledge Base at '{resolved}' is empty (0 entries); "
"check the path. Every concept will be reported as unknown."
)
sample = ", ".join(sorted(knowledge_base.by_id)[:20])
raw = f"Loaded {count} entries from {resolved}\nFirst entries: {sample}"
return (knowledge_base, warnings, "", raw)
+10 -6
View File
@@ -8,15 +8,17 @@ ComfyUI.
from __future__ import annotations
import json
from typing import Any
from compiler.builder.prompt_builder import RESERVED_OUTPUTS, build_prompts
from compiler.common.categories import CANONICAL_CATEGORIES
from compiler.common.config import Config
# The stable set of node outputs: one per canonical category, then the reserved
# outputs. Matches the Prompt Outputs produced by build_prompts.
# One output per canonical category, then the reserved outputs (matching the
# Prompt Outputs produced by build_prompts), then a debug `raw` dump.
_OUTPUT_NAMES: tuple[str, ...] = (*CANONICAL_CATEGORIES, *RESERVED_OUTPUTS)
_RETURN_NAMES: tuple[str, ...] = (*_OUTPUT_NAMES, "raw")
class PromptBuilderNode:
@@ -24,8 +26,8 @@ class PromptBuilderNode:
CATEGORY = "Scene Compiler"
FUNCTION = "run"
RETURN_TYPES = ("STRING",) * len(_OUTPUT_NAMES)
RETURN_NAMES = _OUTPUT_NAMES
RETURN_TYPES = ("STRING",) * len(_RETURN_NAMES)
RETURN_NAMES = _RETURN_NAMES
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
@@ -37,7 +39,9 @@ class PromptBuilderNode:
def run(self, category_map: Any, config: Config | None = None) -> tuple[str, ...]:
if category_map is None:
# Upstream produced no Category Map; emit empty strings rather than crash.
return tuple("" for _ in _OUTPUT_NAMES)
return tuple("" for _ in _RETURN_NAMES)
result = build_prompts(category_map, config or Config())
by_name = {output.name: output.value for output in result.data}
return tuple(by_name[name] for name in _OUTPUT_NAMES)
values = tuple(by_name[name] for name in _OUTPUT_NAMES)
raw = json.dumps(by_name, indent=2, ensure_ascii=False)
return (*values, raw)
+11 -6
View File
@@ -12,7 +12,7 @@ from typing import Any
from compiler.common.config import Config
from compiler.resolver.illustrious_resolver import resolve_scene
from .adapters import format_messages, upstream_failure_message
from .adapters import format_messages, to_raw_json, upstream_failure_message
class ResolverNode:
@@ -20,8 +20,8 @@ class ResolverNode:
CATEGORY = "Scene Compiler"
FUNCTION = "run"
RETURN_TYPES = ("RESOLVED_TAGS", "STRING", "STRING")
RETURN_NAMES = ("resolved_tags", "warnings", "errors")
RETURN_TYPES = ("RESOLVED_TAGS", "STRING", "STRING", "STRING")
RETURN_NAMES = ("resolved_tags", "warnings", "errors", "raw")
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
@@ -38,8 +38,13 @@ class ResolverNode:
scene: Any,
knowledge_base: Any,
config: Config | None = None,
) -> tuple[Any, str, str]:
) -> tuple[Any, str, str, str]:
if scene is None or knowledge_base is None:
return (None, "", upstream_failure_message("scene or knowledge base"))
return (None, "", upstream_failure_message("scene or knowledge base"), "")
result = resolve_scene(scene, knowledge_base, config or Config())
return (result.data, format_messages(result.warnings), format_messages(result.errors))
return (
result.data,
format_messages(result.warnings),
format_messages(result.errors),
to_raw_json(result.data),
)
+9 -4
View File
@@ -13,7 +13,7 @@ from compiler.analyzer.backend import OllamaBackend
from compiler.analyzer.scene_analyzer import analyze
from compiler.common.config import Config
from .adapters import format_messages
from .adapters import format_messages, to_raw_json
class SceneAnalyzerNode:
@@ -21,8 +21,8 @@ class SceneAnalyzerNode:
CATEGORY = "Scene Compiler"
FUNCTION = "run"
RETURN_TYPES = ("SCENE", "STRING", "STRING")
RETURN_NAMES = ("scene", "warnings", "errors")
RETURN_TYPES = ("SCENE", "STRING", "STRING", "STRING")
RETURN_NAMES = ("scene", "warnings", "errors", "raw")
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
@@ -57,4 +57,9 @@ class SceneAnalyzerNode:
config = Config.from_json({"analyzer": analyzer})
backend = OllamaBackend.from_config(config)
result = analyze(natural_language, backend, config)
return (result.data, format_messages(result.warnings), format_messages(result.errors))
return (
result.data,
format_messages(result.warnings),
format_messages(result.errors),
to_raw_json(result.data),
)
+11 -6
View File
@@ -12,7 +12,7 @@ from typing import Any
from compiler.common.config import Config
from compiler.validator.scene_validator import validate_scene
from .adapters import format_messages, upstream_failure_message
from .adapters import format_messages, to_raw_json, upstream_failure_message
class SceneValidatorNode:
@@ -20,8 +20,8 @@ class SceneValidatorNode:
CATEGORY = "Scene Compiler"
FUNCTION = "run"
RETURN_TYPES = ("SCENE", "STRING", "STRING")
RETURN_NAMES = ("scene", "warnings", "errors")
RETURN_TYPES = ("SCENE", "STRING", "STRING", "STRING")
RETURN_NAMES = ("scene", "warnings", "errors", "raw")
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
@@ -30,8 +30,13 @@ class SceneValidatorNode:
"optional": {"config": ("COMPILER_CONFIG",)},
}
def run(self, scene: Any, config: Config | None = None) -> tuple[Any, str, str]:
def run(self, scene: Any, config: Config | None = None) -> tuple[Any, str, str, str]:
if scene is None:
return (None, "", upstream_failure_message("scene"))
return (None, "", upstream_failure_message("scene"), "")
result = validate_scene(scene.to_json(), config or Config())
return (result.data, format_messages(result.warnings), format_messages(result.errors))
return (
result.data,
format_messages(result.warnings),
format_messages(result.errors),
to_raw_json(result.data),
)
+4 -4
View File
@@ -49,15 +49,15 @@ def scene() -> Scene:
def test_node_metadata() -> None:
assert ResolverNode.RETURN_TYPES == ("RESOLVED_TAGS", "STRING", "STRING")
assert ResolverNode.RETURN_NAMES == ("resolved_tags", "warnings", "errors")
assert ResolverNode.RETURN_TYPES == ("RESOLVED_TAGS", "STRING", "STRING", "STRING")
assert ResolverNode.RETURN_NAMES == ("resolved_tags", "warnings", "errors", "raw")
inputs = ResolverNode.INPUT_TYPES()
assert set(inputs["required"]) == {"scene", "knowledge_base"}
assert "config" in inputs["optional"]
def test_resolves_tags_with_real_resolver() -> None:
resolved, warnings, errors = ResolverNode().run(scene(), kb())
resolved, warnings, errors, *_ = ResolverNode().run(scene(), kb())
assert [t.tag for t in resolved] == ["1girl", "long hair"]
assert errors == ""
@@ -67,7 +67,7 @@ def test_delegates_and_surfaces_messages(monkeypatch) -> None:
return CompilerResult(data=()).add_warning(message("SC0001", "unknown 'x'"))
monkeypatch.setattr(node_module, "resolve_scene", fake_resolve)
resolved, warnings, errors = ResolverNode().run(scene(), kb())
resolved, warnings, errors, *_ = ResolverNode().run(scene(), kb())
assert resolved == ()
assert "SC0001: unknown 'x'" in warnings
+4 -4
View File
@@ -15,14 +15,14 @@ def rtag(name: str, category: str) -> ResolvedTag:
def test_node_metadata() -> None:
assert CategorySplitterNode.RETURN_TYPES == ("CATEGORY_MAP", "STRING", "STRING")
assert CategorySplitterNode.RETURN_NAMES == ("category_map", "warnings", "errors")
assert CategorySplitterNode.RETURN_TYPES == ("CATEGORY_MAP", "STRING", "STRING", "STRING")
assert CategorySplitterNode.RETURN_NAMES == ("category_map", "warnings", "errors", "raw")
assert "resolved_tags" in CategorySplitterNode.INPUT_TYPES()["required"]
def test_splits_with_real_module() -> None:
tags = (rtag("1girl", "character"), rtag("long hair", "hair"))
category_map, warnings, errors = CategorySplitterNode().run(tags)
category_map, warnings, errors, *_ = CategorySplitterNode().run(tags)
assert isinstance(category_map, CategoryMap)
assert [t.tag for t in category_map.tags_for("character")] == ["1girl"]
assert [t.tag for t in category_map.tags_for("hair")] == ["long hair"]
@@ -35,7 +35,7 @@ def test_delegates_and_surfaces_errors(monkeypatch) -> None:
"split_into_categories",
lambda tags: CompilerResult().add_error(message("SC0008", "bad category")),
)
category_map, warnings, errors = CategorySplitterNode().run(())
category_map, warnings, errors, *_ = CategorySplitterNode().run(())
assert category_map is None
assert "SC0008: bad category" in errors
+22 -5
View File
@@ -15,8 +15,8 @@ def write_kb(directory, entries: list[dict]) -> str:
def test_node_metadata() -> None:
assert KnowledgeBaseLoaderNode.RETURN_TYPES == ("KNOWLEDGE_BASE", "STRING", "STRING")
assert KnowledgeBaseLoaderNode.RETURN_NAMES == ("knowledge_base", "warnings", "errors")
assert KnowledgeBaseLoaderNode.RETURN_TYPES == ("KNOWLEDGE_BASE", "STRING", "STRING", "STRING")
assert KnowledgeBaseLoaderNode.RETURN_NAMES == ("knowledge_base", "warnings", "errors", "raw")
inputs = KnowledgeBaseLoaderNode.INPUT_TYPES()
assert "path" in inputs["required"]
assert "reload" in inputs["optional"]
@@ -24,7 +24,7 @@ def test_node_metadata() -> None:
def test_loads_knowledge_base(tmp_path) -> None:
path = write_kb(tmp_path, [{"id": "long_hair", "tags": ["long hair"], "category": "hair"}])
kb, warnings, errors = KnowledgeBaseLoaderNode().run(path)
kb, warnings, errors, *_ = KnowledgeBaseLoaderNode().run(path)
assert isinstance(kb, KnowledgeBase)
assert kb.get("long_hair") is not None
assert (warnings, errors) == ("", "")
@@ -32,14 +32,31 @@ def test_loads_knowledge_base(tmp_path) -> None:
def test_invalid_knowledge_base_surfaces_sc0004(tmp_path) -> None:
path = write_kb(tmp_path, [{"id": "x", "tags": ["x"], "category": "nope"}])
kb, warnings, errors = KnowledgeBaseLoaderNode().run(path)
kb, warnings, errors, *_ = KnowledgeBaseLoaderNode().run(path)
assert kb is None
assert "SC0004" in errors
def test_relative_path_resolves_against_package(monkeypatch, tmp_path) -> None:
# Even with an unrelated working directory (as under ComfyUI), the default
# relative path must load the shipped Knowledge Base, not an empty one.
monkeypatch.chdir(tmp_path)
kb, warnings, errors, raw = KnowledgeBaseLoaderNode().run("knowledge_base/")
assert isinstance(kb, KnowledgeBase)
assert len(kb) > 100
assert (warnings, errors) == ("", "")
assert "entries" in raw
def test_empty_knowledge_base_warns(tmp_path) -> None:
kb, warnings, errors, raw = KnowledgeBaseLoaderNode().run(str(tmp_path))
assert len(kb) == 0
assert "empty" in warnings.lower()
def test_reload_input_is_accepted(tmp_path) -> None:
path = write_kb(tmp_path, [{"id": "long_hair", "tags": ["long hair"], "category": "hair"}])
kb, _, _ = KnowledgeBaseLoaderNode().run(path, reload=5)
kb, _, _, _ = KnowledgeBaseLoaderNode().run(path, reload=5)
assert isinstance(kb, KnowledgeBase)
+4 -4
View File
@@ -14,25 +14,25 @@ from nodes.scene_validator_node import SceneValidatorNode
def test_validator_handles_none_scene() -> None:
scene, warnings, errors = SceneValidatorNode().run(None)
scene, warnings, errors, *_ = SceneValidatorNode().run(None)
assert scene is None
assert "no output" in errors.lower()
def test_resolver_handles_none_scene() -> None:
resolved, _, errors = ResolverNode().run(None, knowledge_base=object())
resolved, _, errors, *_ = ResolverNode().run(None, knowledge_base=object())
assert resolved is None
assert errors
def test_resolver_handles_none_knowledge_base() -> None:
resolved, _, errors = ResolverNode().run(scene=object(), knowledge_base=None)
resolved, _, errors, *_ = ResolverNode().run(scene=object(), knowledge_base=None)
assert resolved is None
assert errors
def test_category_splitter_handles_none_tags() -> None:
category_map, _, errors = CategorySplitterNode().run(None)
category_map, _, errors, *_ = CategorySplitterNode().run(None)
assert category_map is None
assert errors
+2 -2
View File
@@ -24,8 +24,8 @@ def outputs(result: tuple[str, ...]) -> dict[str, str]:
def test_emits_one_output_per_category_plus_reserved() -> None:
assert PromptBuilderNode.RETURN_NAMES == (*CANONICAL_CATEGORIES, "negative", "scene")
assert PromptBuilderNode.RETURN_TYPES == ("STRING",) * 21
assert PromptBuilderNode.RETURN_NAMES == (*CANONICAL_CATEGORIES, "negative", "scene", "raw")
assert PromptBuilderNode.RETURN_TYPES == ("STRING",) * 22
def test_formats_and_orders_category_strings() -> None:
+4 -4
View File
@@ -21,8 +21,8 @@ def test_input_types_structure() -> None:
def test_node_metadata() -> None:
assert SceneAnalyzerNode.RETURN_TYPES == ("SCENE", "STRING", "STRING")
assert SceneAnalyzerNode.RETURN_NAMES == ("scene", "warnings", "errors")
assert SceneAnalyzerNode.RETURN_TYPES == ("SCENE", "STRING", "STRING", "STRING")
assert SceneAnalyzerNode.RETURN_NAMES == ("scene", "warnings", "errors", "raw")
assert SceneAnalyzerNode.FUNCTION == "run"
assert SceneAnalyzerNode.CATEGORY == "Scene Compiler"
@@ -41,7 +41,7 @@ def test_run_delegates_to_analyzer_and_surfaces_messages(monkeypatch) -> None:
monkeypatch.setattr(node_module, "analyze", fake_analyze)
scene, warnings, errors = SceneAnalyzerNode().run(
scene, warnings, errors, *_ = SceneAnalyzerNode().run(
natural_language="A girl.",
model_name="mistral",
temperature=0.0,
@@ -56,7 +56,7 @@ def test_run_delegates_to_analyzer_and_surfaces_messages(monkeypatch) -> None:
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", "llama3", 0.0, 60)
assert (warnings, errors) == ("", "")
+10 -4
View File
@@ -37,8 +37,8 @@ def sample_scene() -> Scene:
def test_node_metadata() -> None:
assert SceneValidatorNode.RETURN_TYPES == ("SCENE", "STRING", "STRING")
assert SceneValidatorNode.RETURN_NAMES == ("scene", "warnings", "errors")
assert SceneValidatorNode.RETURN_TYPES == ("SCENE", "STRING", "STRING", "STRING")
assert SceneValidatorNode.RETURN_NAMES == ("scene", "warnings", "errors", "raw")
assert SceneValidatorNode.CATEGORY == "Scene Compiler"
inputs = SceneValidatorNode.INPUT_TYPES()
assert "scene" in inputs["required"]
@@ -46,11 +46,17 @@ def test_node_metadata() -> None:
def test_valid_scene_passes_through() -> None:
scene, warnings, errors = SceneValidatorNode().run(sample_scene())
scene, warnings, errors, *_ = SceneValidatorNode().run(sample_scene())
assert isinstance(scene, Scene)
assert (warnings, errors) == ("", "")
def test_raw_output_contains_scene_json() -> None:
_, _, _, raw = SceneValidatorNode().run(sample_scene())
assert '"characters"' in raw # the debug raw output is the scene as JSON
assert "female" in raw
def test_delegates_and_surfaces_messages(monkeypatch) -> None:
captured = {}
@@ -64,7 +70,7 @@ def test_delegates_and_surfaces_messages(monkeypatch) -> None:
monkeypatch.setattr(node_module, "validate_scene", fake_validate)
config = Config.from_json({"validator": {"allow_unknown_fields": True}})
scene, warnings, errors = SceneValidatorNode().run(sample_scene(), config)
scene, warnings, errors, *_ = SceneValidatorNode().run(sample_scene(), config)
assert scene == "VALIDATED"
assert captured["allow_unknown"] is True
assert "SC0015: unexpected field removed" in warnings