diff --git a/api/controllers/console/evaluation/evaluation.py b/api/controllers/console/evaluation/evaluation.py index 362e613b40..cdff4ef482 100644 --- a/api/controllers/console/evaluation/evaluation.py +++ b/api/controllers/console/evaluation/evaluation.py @@ -483,6 +483,48 @@ class EvaluationMetricsApi(Resource): return {"metrics": result} +@console_ns.route("///evaluation/node-info") +class EvaluationNodeInfoApi(Resource): + @console_ns.doc("get_evaluation_node_info") + @console_ns.response(200, "Node info grouped by metric") + @console_ns.response(404, "Target not found") + @setup_required + @login_required + @account_initialization_required + @get_evaluation_target + def post(self, target: Union[App, CustomizedSnippet], target_type: str): + """Return workflow/snippet node info grouped by requested metrics. + + Request body (JSON): + - metrics: list[str] | None – metric names to query; omit or pass + an empty list to get all nodes under key ``"all"``. + + Response: + ``{metric_or_all: [{"node_id": ..., "type": ..., "title": ...}, ...]}`` + """ + body = request.get_json(silent=True) or {} + metrics: list[str] | None = body.get("metrics") or None + + result = EvaluationService.get_nodes_for_metrics( + target=target, + target_type=target_type, + metrics=metrics, + ) + return result + + +@console_ns.route("/evaluation/available-metrics") +class EvaluationAvailableMetricsApi(Resource): + @console_ns.doc("get_available_evaluation_metrics") + @console_ns.response(200, "Available metrics list") + @setup_required + @login_required + @account_initialization_required + def get(self): + """Return the centrally-defined list of evaluation metrics.""" + return {"metrics": EvaluationService.get_available_metrics()} + + @console_ns.route("///evaluation/files/") class EvaluationFileDownloadApi(Resource): @console_ns.doc("download_evaluation_file") diff --git a/api/controllers/console/workspace/snippets.py b/api/controllers/console/workspace/snippets.py index bb5fea043e..1366e8ffff 100644 --- a/api/controllers/console/workspace/snippets.py +++ b/api/controllers/console/workspace/snippets.py @@ -1,6 +1,7 @@ import logging +from urllib.parse import quote -from flask import request +from flask import Response, request from flask_restx import Resource, marshal from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound @@ -239,7 +240,18 @@ class CustomizedSnippetExportApi(Resource): export_service = SnippetDslService(session) result = export_service.export_snippet_dsl(snippet=snippet, include_secret=query.include_secret == "true") - return {"data": result}, 200 + # Set filename with .snippet extension + filename = f"{snippet.name}.snippet" + encoded_filename = quote(filename) + + response = Response( + result, + mimetype="application/x-yaml", + ) + response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}" + response.headers["Content-Type"] = "application/x-yaml" + + return response @console_ns.route("/workspaces/current/customized-snippets/imports") diff --git a/api/services/evaluation_service.py b/api/services/evaluation_service.py index 16a4ee347d..74d4ed0335 100644 --- a/api/services/evaluation_service.py +++ b/api/services/evaluation_service.py @@ -11,12 +11,15 @@ from sqlalchemy.orm import Session from configs import dify_config from core.evaluation.entities.evaluation_entity import ( + EVALUATION_METRICS, + METRIC_NODE_TYPE_MAPPING, DefaultMetric, EvaluationCategory, EvaluationConfigData, EvaluationDatasetInput, EvaluationRunData, EvaluationRunRequest, + NodeInfo, ) from core.evaluation.evaluation_manager import EvaluationManager from core.workflow.enums import WorkflowNodeExecutionMetadataKey @@ -423,6 +426,75 @@ class EvaluationService: def get_supported_metrics(cls, category: EvaluationCategory) -> list[str]: return EvaluationManager.get_supported_metrics(category) + @staticmethod + def get_available_metrics() -> list[str]: + """Return the centrally-defined list of evaluation metrics.""" + return list(EVALUATION_METRICS) + + @classmethod + def get_nodes_for_metrics( + cls, + target: Union[App, CustomizedSnippet], + target_type: str, + metrics: list[str] | None = None, + ) -> dict[str, list[dict[str, str]]]: + """Return node info grouped by metric (or all nodes when *metrics* is empty). + + :param target: App or CustomizedSnippet instance. + :param target_type: ``"app"`` or ``"snippets"``. + :param metrics: Optional list of metric names to filter by. + When *None* or empty, returns ``{"all": []}``. + :returns: ``{metric_name: [NodeInfo dict, ...]}`` or + ``{"all": [NodeInfo dict, ...]}``. + """ + workflow = cls._resolve_workflow(target, target_type) + if not workflow: + return {"all": []} if not metrics else {m: [] for m in metrics} + + if not metrics: + all_nodes = [ + NodeInfo(node_id=node_id, type=node_data.get("type", ""), title=node_data.get("title", "")).model_dump() + for node_id, node_data in workflow.walk_nodes() + ] + return {"all": all_nodes} + + node_type_to_nodes: dict[str, list[dict[str, str]]] = {} + for node_id, node_data in workflow.walk_nodes(): + ntype = node_data.get("type", "") + node_type_to_nodes.setdefault(ntype, []).append( + NodeInfo(node_id=node_id, type=ntype, title=node_data.get("title", "")).model_dump() + ) + + result: dict[str, list[dict[str, str]]] = {} + for metric in metrics: + required_node_type = METRIC_NODE_TYPE_MAPPING.get(metric) + if required_node_type is None: + result[metric] = [] + continue + result[metric] = node_type_to_nodes.get(required_node_type, []) + return result + + @classmethod + def _resolve_workflow( + cls, + target: Union[App, CustomizedSnippet], + target_type: str, + ) -> "Workflow | None": + """Resolve the *published* (preferred) or *draft* workflow for the target.""" + if target_type == "snippets" and isinstance(target, CustomizedSnippet): + snippet_service = SnippetService() + workflow = snippet_service.get_published_workflow(snippet=target) + if not workflow: + workflow = snippet_service.get_draft_workflow(snippet=target) + return workflow + elif target_type == "app" and isinstance(target, App): + workflow_service = WorkflowService() + workflow = workflow_service.get_published_workflow(app_model=target) + if not workflow: + workflow = workflow_service.get_draft_workflow(app_model=target) + return workflow + return None + # ---- Category Resolution ---- @classmethod diff --git a/api/services/snippet_dsl_service.py b/api/services/snippet_dsl_service.py index c6a31c3ceb..a270389ddc 100644 --- a/api/services/snippet_dsl_service.py +++ b/api/services/snippet_dsl_service.py @@ -32,6 +32,12 @@ IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB CURRENT_DSL_VERSION = "0.1.0" +# List of node types that are not allowed in snippets +FORBIDDEN_NODE_TYPES = [ + NodeType.START.value, # "start" + NodeType.HUMAN_INPUT.value, # "human-input" +] + class ImportMode(StrEnum): YAML_CONTENT = "yaml-content" @@ -213,6 +219,28 @@ class SnippetDslService: error="Missing snippet data in YAML content", ) + # Validate workflow nodes - check for forbidden node types + workflow_data = data.get("workflow", {}) + if workflow_data: + graph = workflow_data.get("graph", {}) + nodes = graph.get("nodes", []) + forbidden_nodes_found = [] + for node in nodes: + node_data = node.get("data", {}) + if not node_data: + continue + node_type = node_data.get("type", "") + if node_type in FORBIDDEN_NODE_TYPES: + forbidden_nodes_found.append(node_type) + + if forbidden_nodes_found: + forbidden_types_str = ", ".join(set(forbidden_nodes_found)) + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error=f"Snippet cannot contain the following node types: {forbidden_types_str}", + ) + # If snippet_id is provided, check if it exists snippet = None if snippet_id: