diff --git a/api/services/dify_builder/agent/build.py b/api/services/dify_builder/agent/build.py index 8cbc1a83ba6..164c2cdc3e9 100644 --- a/api/services/dify_builder/agent/build.py +++ b/api/services/dify_builder/agent/build.py @@ -5,6 +5,7 @@ Each degrades to an honest result on model-None / provider-error / parse-fail rather than crashing the advance. build_nodes lives in the same module (added in Task A6).""" +import logging from typing import Any from core.app.app_config.entities import ModelConfig @@ -16,6 +17,8 @@ from services.dify_builder.agent import graph_translate, llm, resources from services.dify_builder.agent.model_resolver import resolve_model_instance from services.workflow_generator_service import WorkflowGeneratorService +logger = logging.getLogger(__name__) + def analyze_goal(model, goal_text: str) -> dict[str, Any]: if model is None: @@ -152,12 +155,23 @@ def build_nodes(tenant_id: str, model_config: dict[str, Any], plan_items: list[s ) graph = result.get("graph") or {} if result.get("error") or not graph.get("nodes"): + logger.warning( + "Dify Builder: build_nodes produced no graph for tenant %s (%d plan items): error=%s", + tenant_id, + len(plan_items), + result.get("error"), + ) return [] intents = graph_translate.to_intents(graph) _ground(intents, mc, tenant_id, plan_items) applicable, _rejected = graph_ops.filter_applicable({"nodes": [], "edges": []}, intents, _ALLOWED_NODE_TYPES) return applicable except Exception: # any generation/translation failure -> honest empty build + logger.exception( + "Dify Builder: build_nodes generation failed for tenant %s (%d plan items); returning empty build", + tenant_id, + len(plan_items), + ) return [] diff --git a/api/services/dify_builder/agent/resources.py b/api/services/dify_builder/agent/resources.py index f6fd9714289..c56a77d3771 100644 --- a/api/services/dify_builder/agent/resources.py +++ b/api/services/dify_builder/agent/resources.py @@ -5,6 +5,7 @@ models, datasets, and tools normalized to id+label. Each source degrades to an empty list on failure so a missing provider never breaks the advance. """ +import logging from dataclasses import dataclass from typing import Any @@ -16,6 +17,8 @@ from extensions.ext_database import db from models.dataset import Dataset from services.model_provider_service import ModelProviderService +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class ResourceRef: @@ -45,10 +48,15 @@ def _list_tools(tenant_id: str) -> list[dict]: return list(build_tool_catalogue(tenant_id)) -def _safe(build) -> list: +def _safe(build, source: str) -> list: try: return build() except Exception: # a missing/mis-configured source degrades to empty, never raises + logger.warning( + "Dify Builder: resource source %r failed for the current tenant; treating as empty", + source, + exc_info=True, + ) return [] @@ -72,7 +80,7 @@ def list_tenant_resources(tenant_id: str) -> TenantResources: for t in _list_tools(tenant_id) ] - models = _safe(build_models) - datasets = _safe(build_datasets) - tools = _safe(build_tools) + models = _safe(build_models, "models") + datasets = _safe(build_datasets, "datasets") + tools = _safe(build_tools, "tools") return TenantResources(models=models, datasets=datasets, tools=tools) diff --git a/api/tests/unit_tests/services/dify_builder/test_build_nodes.py b/api/tests/unit_tests/services/dify_builder/test_build_nodes.py index 9d79fefd9fa..7170252d6b6 100644 --- a/api/tests/unit_tests/services/dify_builder/test_build_nodes.py +++ b/api/tests/unit_tests/services/dify_builder/test_build_nodes.py @@ -1,3 +1,4 @@ +import logging from unittest.mock import patch from services.dify_builder.agent import build, resources @@ -166,3 +167,35 @@ def test_build_nodes_dataset_ids_are_independent_lists_per_node(): assert kb_configs[0]["dataset_ids"] == ["kb-real"] assert kb_configs[1]["dataset_ids"] == ["kb-real"] assert kb_configs[0]["dataset_ids"] is not kb_configs[1]["dataset_ids"] + + +def test_build_nodes_logs_when_generator_reports_error(caplog): + with patch.object(build, "_generator_model_config", return_value=_fake_mc("anthropic", "x")), \ + patch( + "services.dify_builder.agent.build.WorkflowGeneratorService.generate_workflow_graph", + return_value={"graph": {}, "error": "generator boom", "errors": []}, + ), \ + caplog.at_level(logging.WARNING, logger="services.dify_builder.agent.build"): + out = build.build_nodes("t1", {}, ["do a thing"]) + + assert out == [] + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "no graph" in warnings[0].getMessage() + assert "generator boom" in warnings[0].getMessage() + + +def test_build_nodes_logs_traceback_when_generation_raises(caplog): + with patch.object(build, "_generator_model_config", return_value=_fake_mc("anthropic", "x")), \ + patch( + "services.dify_builder.agent.build.WorkflowGeneratorService.generate_workflow_graph", + side_effect=RuntimeError("kaboom"), + ), \ + caplog.at_level(logging.ERROR, logger="services.dify_builder.agent.build"): + out = build.build_nodes("t1", {}, ["do a thing"]) + + assert out == [] + errors = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(errors) == 1 + assert "generation failed" in errors[0].getMessage() + assert errors[0].exc_info is not None diff --git a/api/tests/unit_tests/services/dify_builder/test_resources.py b/api/tests/unit_tests/services/dify_builder/test_resources.py index bdb13009988..867cc14565b 100644 --- a/api/tests/unit_tests/services/dify_builder/test_resources.py +++ b/api/tests/unit_tests/services/dify_builder/test_resources.py @@ -1,3 +1,4 @@ +import logging from unittest.mock import patch @@ -71,3 +72,28 @@ def test_malformed_tool_record_degrades(): assert out.tools == [] assert out.models == [] assert out.datasets == [] + + +def test_list_tenant_resources_logs_when_a_source_fails(caplog): + from services.dify_builder.agent import resources + + class _Model: + def __init__(self, m): + self.model = m + + class _ProvResp: + def __init__(self, p, ms): + self.provider, self.models = p, [_Model(m) for m in ms] + + with patch.object(resources, "_list_models", return_value=[_ProvResp("anthropic", ["claude-opus-4-8"])]), \ + patch.object(resources, "_list_datasets", side_effect=RuntimeError("db down")), \ + patch.object(resources, "_list_tools", return_value=[]), \ + caplog.at_level(logging.WARNING, logger="services.dify_builder.agent.resources"): + out = resources.list_tenant_resources("t1") + + assert out.datasets == [] + assert out.models[0].id == "anthropic/claude-opus-4-8" + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "datasets" in warnings[0].getMessage() + assert warnings[0].exc_info is not None