Files
goodguy1963-ComfyUI-Thinkin…/tests/test_decision_fields.py
T
Marcel Hofer 163ff56c56 feat: add typed Decision Fields for v3.1.0
Add image and text Decision Fields with independent BOOLEAN conditions and bounded INT outputs. Keep probability-rich decision_json and add a readable multiline decision output. Support local in-process llama.cpp and native OpenAI/OpenRouter Decisions API requests. Preserve dynamic output connections and migrate earlier workflow layouts; add explanatory notes to both frontends and update release metadata and documentation.

Feature idea contributed by JMarimon1 (Reddit feature request): https://www.reddit.com/user/JMarimon1/

Validation: 257 embedded ComfyUI Python tests and 6 frontend tests passed; catalog and mirrored-version checks passed.
2026-10-30 16:07:51 +01:00

364 lines
18 KiB
Python

from __future__ import annotations
import importlib.util
import json
import sys
import types
import unittest
from pathlib import Path
from unittest import mock
import torch
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from thinkingllm_core import decision_api
from thinkingllm_core.decision_fields import (
MAX_BOOLEAN_FIELDS,
MAX_DECISION_FIELDS,
MAX_INTEGER_FIELDS,
field_routes,
normalize_field_result,
normalize_fields,
)
def load_router_module():
module_name = "thinkingllm_decision_fields_test"
spec = importlib.util.spec_from_file_location(module_name, ROOT / "nodes" / "decision_router.py")
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def field_values():
values = [("", "BOOLEAN", "", 0.5, 0, 0) for _ in range(MAX_BOOLEAN_FIELDS)]
values += [("", "INT", "", 0.5, 0, 10) for _ in range(MAX_INTEGER_FIELDS)]
values[0] = ("is_fox", "BOOLEAN", "Is a fox visible?", 0.6, 0, 0)
values[1] = ("is_dog", "BOOLEAN", "Is a dog visible?", 0.5, 0, 0)
values[MAX_BOOLEAN_FIELDS] = ("people_count", "INT", "How many people are visible?", 0.5, 0, 2)
return values
def normalized_fields():
return normalize_fields(field_values())
class DecisionFieldsSchemaTests(unittest.TestCase):
def test_fields_keep_stable_slots_and_build_bounded_options(self):
fields = normalized_fields()
self.assertEqual([field["id"] for field in fields], ["field_01", "field_02", "field_09"])
self.assertEqual([field["slot"] for field in fields], [0, 1, 8])
self.assertEqual([option["value"] for option in fields[0]["options"]], ["true", "false"])
self.assertEqual([option["value"] for option in fields[2]["options"]], ["0", "1", "2", ">max"])
self.assertEqual(len(fields), 3)
def test_bool_threshold_is_applied_to_true_probability(self):
field = normalized_fields()[0]
result = normalize_field_result(field, "false", {"true": 0.7, "false": 0.3})
self.assertIs(result["value"], True)
self.assertEqual(result["choice"], "false")
def test_integer_overflow_is_clamped_and_marked(self):
field = normalized_fields()[2]
probabilities = {option["value"]: 0.0 for option in field["options"]}
probabilities[">max"] = 1.0
result = normalize_field_result(field, ">max", probabilities)
self.assertEqual(result["value"], 2)
self.assertEqual(result["range_status"], "above_max")
def test_integer_exact_values_are_native_ints(self):
field = normalized_fields()[2]
probabilities = {option["value"]: 0.0 for option in field["options"]}
probabilities["1"] = 1.0
result = normalize_field_result(field, "1", probabilities)
self.assertEqual(result["value"], 1)
self.assertEqual(result["range_status"], "exact")
self.assertIs(type(result["value"]), int)
def test_invalid_fields_fail_before_inference(self):
with self.assertRaisesRegex(ValueError, "needs a question"):
normalize_fields([("is_fox", "BOOLEAN", "", 0.5, 0, 0)])
with self.assertRaisesRegex(ValueError, "unique"):
normalize_fields([
("is_fox", "BOOLEAN", "Fox visible?", 0.5, 0, 0),
("IS_FOX", "BOOLEAN", "Another question?", 0.5, 0, 0),
])
with self.assertRaisesRegex(ValueError, "supports at most 52 choices"):
normalize_fields([("count", "INT", "Count?", 0.5, 0, 51)])
with self.assertRaisesRegex(ValueError, "at most 8 BOOLEAN"):
normalize_fields([
(f"bool_{index}", "BOOLEAN", f"Condition {index}?", 0.5, 0, 0)
for index in range(MAX_BOOLEAN_FIELDS + 1)
])
class DecisionFieldsApiTests(unittest.TestCase):
def test_openai_request_batches_multiple_questions_and_image_once(self):
fields = normalized_fields()
payload = decision_api.build_fields_decision_request(
"openai_decisions", "gpt-6-luna", "image", None,
"data:image/png;base64,AA==", "Inspect this image.", fields,
)
self.assertEqual(len(payload["questions"]), 3)
self.assertEqual(payload["questions"][0]["name"], "field_01")
self.assertEqual(payload["questions"][0]["choices"][0]["value"], "true")
self.assertEqual(payload["questions"][2]["choices"][-1]["value"], ">max")
self.assertEqual(sum(part["type"] == "input_image" for part in payload["input"][0]["content"]), 1)
def test_openrouter_request_uses_named_systemone_fields(self):
fields = normalized_fields()
payload = decision_api.build_fields_decision_request(
"openrouter_decisions", "typesafe/jev", "text", "two people", None, "Shared instruction.", fields,
)
self.assertEqual(set(payload["questions"]), {"field_01", "field_02", "field_09"})
self.assertEqual(payload["questions"]["field_01"]["type"], "choice")
self.assertEqual(payload["questions"]["field_09"]["criteria"][">max"], "The integer is greater than 2.")
def test_openai_and_openrouter_answer_schemas_normalize_by_field_id(self):
fields = normalized_fields()
openai_answers = []
openrouter_answers = {}
for field in fields:
values = [str(option["value"]) for option in field["options"]]
chosen = values[0]
probs = {value: (1.0 if value == chosen else 0.0) for value in values}
openai_answers.append({
"name": field["id"], "type": "choice", "choice": chosen, "confidence": 0.9,
"probabilities": [{"value": value, "probability": probability} for value, probability in probs.items()],
})
openrouter_answers[field["id"]] = {
"type": "choice", "choice": chosen, "confidence": 0.9, "probabilities": probs,
}
openai = decision_api.normalize_fields_decision_response("openai_decisions", {"answers": openai_answers}, fields)
openrouter = decision_api.normalize_fields_decision_response("openrouter_decisions", {"answers": openrouter_answers}, fields)
self.assertEqual(openai, openrouter)
self.assertEqual(openai["field_01"][0], "true")
def test_provider_missing_or_refused_field_fails_closed(self):
fields = normalized_fields()
with self.assertRaisesRegex(RuntimeError, "did not return a choice"):
decision_api.normalize_fields_decision_response("openai_decisions", {"answers": []}, fields)
with self.assertRaisesRegex(RuntimeError, "did not return a choice"):
decision_api.normalize_fields_decision_response(
"openrouter_decisions",
{"answers": {"field_01": {"type": "refusal"}}},
fields,
)
class DecisionFieldsNodeTests(unittest.TestCase):
def test_node_contract_uses_dynamic_output_slots_and_list_batch_outputs(self):
module = load_router_module()
image_node = module.ThinkingLLMDecisionFieldsImage
text_node = module.ThinkingLLMDecisionFieldsText
expected_types = ("STRING", "STRING") + ("*",) * MAX_DECISION_FIELDS
self.assertEqual(image_node.RETURN_TYPES, expected_types)
self.assertEqual(text_node.RETURN_TYPES, expected_types)
self.assertEqual(len(image_node.RETURN_NAMES), MAX_DECISION_FIELDS + 2)
self.assertEqual(image_node.RETURN_NAMES[0], "decision_json")
self.assertEqual(image_node.RETURN_NAMES[1], "decision")
self.assertEqual(image_node.OUTPUT_IS_LIST, (False, False) + (True,) * MAX_DECISION_FIELDS)
self.assertEqual(image_node.INPUT_TYPES()["required"]["image"][0], "IMAGE")
self.assertEqual(text_node.INPUT_TYPES()["required"]["text"][0], "STRING")
self.assertEqual(image_node.INPUT_TYPES()["required"]["decision_output_slots"][1]["default"], "[]")
self.assertEqual(
module.NODE_CLASS_MAPPINGS["ThinkingLLM_DecisionFieldsImage"],
image_node,
)
self.assertEqual(
module.NODE_CLASS_MAPPINGS["ThinkingLLM_DecisionFieldsText"],
text_node,
)
def test_readable_decision_output_is_multiline_without_probability_noise(self):
module = load_router_module()
result = module._DecisionFieldsBase._format_decision_text("image", [{
"image_index": 0,
"fields": [
{"name": "dog_visible", "value": True, "probabilities": {"true": 0.9670209}},
{"name": "person_count", "value": 2, "range_status": "exact"},
{"name": "upper_count", "value": 3, "range_status": "above_max"},
],
}])
self.assertEqual(result, "Decision results (image)\nImage 1\n dog_visible: true\n person_count: 2\n upper_count: 3 (above maximum)")
self.assertNotIn("0.967", result)
def test_readable_decision_output_handles_text_batches_and_empty_batches(self):
module = load_router_module()
text = module._DecisionFieldsBase._format_decision_text("text", [
{"text_index": 0, "fields": [{"name": "is_fox", "value": False}]},
{"text_index": 1, "fields": [{"name": "is_fox", "value": True}]},
])
self.assertEqual(text, "Decision results (text)\nText 1\n is_fox: false\nText 2\n is_fox: true")
self.assertEqual(module._DecisionFieldsBase._format_decision_text("text", []), "No text items were evaluated.")
def test_text_batch_returns_independent_boolean_and_integer_values(self):
module = load_router_module()
node = module.ThinkingLLMDecisionFieldsText()
node._load_model = lambda *_args, **_kwargs: setattr(node, "llm", object())
node.clear = mock.Mock()
def fake_probabilities(_llm, text, instruction, routes):
names = [route["name"] for route in routes]
if names == ["true", "false"]:
is_first = text == "first"
if "fox" in instruction:
return [0.8, 0.2] if is_first else [0.3, 0.7]
return [0.7, 0.3] if is_first else [0.2, 0.8]
if ">max" in names:
return [0.0, 0.0, 1.0, 0.0] if text == "first" else [0.0, 0.0, 0.0, 1.0]
raise AssertionError(f"Unexpected choices: {names}")
with mock.patch.object(module, "openjev_text_probabilities", side_effect=fake_probabilities):
result = node.evaluate_text(
["first", "second"],
"OpenJev 27B · Q4_K_M · non-commercial",
"Evaluate each property.",
keep_model_loaded=True,
boolean_01_name="is_fox",
boolean_01_question="Is a fox visible?",
boolean_02_name="is_dog",
boolean_02_question="Is a dog visible?",
integer_01_name="people_count",
integer_01_question="How many people are visible?",
integer_01_minimum=0,
integer_01_maximum=2,
decision_output_slots='["integer_01", "boolean_02", "boolean_01"]',
)
payload = json.loads(result[0])
self.assertEqual([item["text_index"] for item in payload["decisions"]], [0, 1])
self.assertEqual([field["value"] for field in payload["decisions"][0]["fields"]], [True, True, 2])
self.assertEqual(result[1], "Decision results (text)\nText 1\n is_fox: true\n is_dog: true\n people_count: 2\nText 2\n is_fox: false\n is_dog: false\n people_count: 2 (above maximum)")
self.assertEqual(result[2], [2, 2])
self.assertEqual(result[3], [True, False])
self.assertEqual(result[4], [True, False])
self.assertEqual(payload["decisions"][1]["fields"][2]["range_status"], "above_max")
self.assertEqual(len(result), 5)
node.clear.assert_not_called()
def test_image_batch_returns_values_in_original_order(self):
module = load_router_module()
node = module.ThinkingLLMDecisionFieldsImage()
node._load_model = lambda *_args, **_kwargs: setattr(node, "llm", object())
node.clear = mock.Mock()
images = torch.stack([torch.zeros((2, 2, 3)), torch.ones((2, 2, 3))])
def fake_probabilities(_llm, _handler, image_data_url, _instruction, _routes):
return [0.9, 0.1] if image_data_url == "first-image" else [0.1, 0.9]
with (
mock.patch.object(module, "_encode_image", side_effect=lambda image: "first-image" if float(image[0, 0, 0]) == 0 else "second-image"),
mock.patch.object(module, "openjev_probabilities", side_effect=fake_probabilities),
):
result = node.evaluate_images(
images,
"OpenJev 27B · Q4_K_M · non-commercial",
"Check the visible subject.",
keep_model_loaded=True,
boolean_01_name="is_fox",
boolean_01_question="Is a fox visible?",
decision_output_slots='["boolean_01", "boolean_02"]',
)
self.assertEqual(result[1], "Decision results (image)\nImage 1\n is_fox: true\nImage 2\n is_fox: false")
self.assertEqual(result[2], [True, False])
payload = json.loads(result[0])
self.assertEqual(payload["input_type"], "image")
self.assertEqual([item["image_index"] for item in payload["decisions"]], [0, 1])
self.assertIsInstance(result[3], module.ExecutionBlocker)
self.assertEqual(len(result), 4)
node.clear.assert_not_called()
def test_empty_batch_blocks_typed_outputs_without_loading_model(self):
module = load_router_module()
node = module.ThinkingLLMDecisionFieldsText()
node._load_model = mock.Mock(side_effect=AssertionError("empty batch must not load a model"))
node.clear = mock.Mock()
result = node.evaluate_text(
[], "Clef-Flash 9B · Q4_K_M", "Check the input.",
boolean_01_name="has_text", boolean_01_question="Does the input contain text?",
)
self.assertEqual(json.loads(result[0])["decisions"], [])
self.assertEqual(len(result), 3)
self.assertEqual(result[1], "No text items were evaluated.")
self.assertIsInstance(result[2], module.ExecutionBlocker)
node._load_model.assert_not_called()
node.clear.assert_called_once_with()
def test_api_node_uses_one_multifield_request_per_input(self):
module = load_router_module()
node = module.ThinkingLLMDecisionFieldsText()
profile = {
"name": "Test OpenAI", "provider": "OpenAI", "decision_protocol": "openai_decisions",
"decision_endpoint": "https://api.openai.com/v1/decisions", "allow_images": True,
}
api_module = types.ModuleType("openai_compatible_api")
api_module._load_api_profiles = lambda: {"Test OpenAI": profile}
api_module._credential_value = lambda _profile: "test-secret"
api_module._NO_REDIRECT_OPENER = object()
api_module._image_to_data_url = lambda *_args: "data:image/png;base64,AA=="
api_module._validate_input_text = lambda *_args: None
previous_api_module = sys.modules.get("openai_compatible_api")
try:
with mock.patch.dict(sys.modules, {"openai_compatible_api": api_module}):
with (
mock.patch.object(decision_api, "decision_models_for_profile", return_value=[{"id": "gpt-6-luna"}]),
mock.patch.object(
decision_api,
"request_decision_fields",
side_effect=[
{
"field_01": ("true", {"true": 0.9, "false": 0.1}, 0.9),
"field_09": ("2", {"0": 0.0, "1": 0.0, "2": 1.0, ">max": 0.0}, 0.9),
},
{
"field_01": ("false", {"true": 0.1, "false": 0.9}, 0.9),
"field_09": (">max", {"0": 0.0, "1": 0.0, "2": 0.0, ">max": 1.0}, 0.9),
},
],
) as request_mock,
):
result = node.evaluate_text(
["first", "second"], "unused local", "Inspect each item.",
decision_backend="API", api_profile="Test OpenAI", api_model="gpt-6-luna",
boolean_01_name="is_fox", boolean_01_question="Is a fox visible?",
integer_01_name="people_count", integer_01_question="How many people?",
integer_01_minimum=0, integer_01_maximum=2,
)
finally:
if previous_api_module is None:
sys.modules.pop("openai_compatible_api", None)
else:
sys.modules["openai_compatible_api"] = previous_api_module
self.assertEqual(request_mock.call_count, 2)
self.assertEqual(result[1], "Decision results (text)\nText 1\n is_fox: true\n people_count: 2\nText 2\n is_fox: false\n people_count: 2 (above maximum)")
self.assertEqual(result[2], [True, False])
self.assertEqual(result[3], [2, 2])
self.assertEqual(json.loads(result[0])["provider"], "OpenAI")
def test_stale_or_invalid_dynamic_output_schema_fails_before_inference(self):
module = load_router_module()
node = module.ThinkingLLMDecisionFieldsText()
node._load_model = mock.Mock(side_effect=AssertionError("invalid output schema must fail before loading"))
node.clear = mock.Mock()
with self.assertRaisesRegex(ValueError, "out of sync"):
node.evaluate_text(
"A fox is visible.", "Clef-Flash 9B · Q4_K_M", "Check the text.",
boolean_01_name="is_fox", boolean_01_question="Is a fox visible?",
integer_01_name="count", integer_01_question="How many foxes?",
decision_output_slots='["boolean_01"]',
)
node._load_model.assert_not_called()
if __name__ == "__main__":
unittest.main()