From a20c6bd4bca1bed7d33ef91aba900adf4df806f0 Mon Sep 17 00:00:00 2001 From: FFXN Date: Mon, 30 Mar 2026 18:18:57 +0800 Subject: [PATCH] feat: get available evaluation workflow. --- api/controllers/console/app/workflow.py | 1 + .../console/evaluation/evaluation.py | 109 +++++++++++++++++- api/services/workflow_service.py | 56 ++++++++- 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index c9953a0962..48ff0d6d83 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -158,6 +158,7 @@ class WorkflowListQuery(BaseModel): limit: int = Field(default=10, ge=1, le=100) user_id: str | None = None named_only: bool = False + keyword: str | None = Field(default=None, max_length=255) class WorkflowUpdatePayload(BaseModel): diff --git a/api/controllers/console/evaluation/evaluation.py b/api/controllers/console/evaluation/evaluation.py index 12fa5f5a4b..42b18eda66 100644 --- a/api/controllers/console/evaluation/evaluation.py +++ b/api/controllers/console/evaluation/evaluation.py @@ -7,14 +7,15 @@ from typing import TYPE_CHECKING, ParamSpec, TypeVar, Union from urllib.parse import quote from flask import Response, request -from flask_restx import Resource, fields +from flask_restx import Resource, fields, marshal from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session -from werkzeug.exceptions import BadRequest, NotFound +from werkzeug.exceptions import BadRequest, Forbidden, NotFound from controllers.common.schema import register_schema_models from controllers.console import console_ns +from controllers.console.app.workflow import WorkflowListQuery from controllers.console.wraps import ( account_initialization_required, edit_permission_required, @@ -23,6 +24,7 @@ from controllers.console.wraps import ( from core.evaluation.entities.evaluation_entity import EvaluationCategory, EvaluationConfigData, EvaluationRunRequest from extensions.ext_database import db from extensions.ext_storage import storage +from fields.member_fields import simple_account_fields from graphon.file import helpers as file_helpers from libs.helper import TimestampField from libs.login import current_account_with_tenant, login_required @@ -36,6 +38,7 @@ from services.errors.evaluation import ( EvaluationNotFoundError, ) from services.evaluation_service import EvaluationService +from services.workflow_service import WorkflowService if TYPE_CHECKING: from models.evaluation import EvaluationRun, EvaluationRunItem @@ -125,6 +128,33 @@ evaluation_detail_fields = { evaluation_detail_model = console_ns.model("EvaluationDetail", evaluation_detail_fields) +available_evaluation_workflow_list_fields = { + "id": fields.String, + "app_id": fields.String, + "app_name": fields.String, + "type": fields.String, + "version": fields.String, + "marked_name": fields.String, + "marked_comment": fields.String, + "hash": fields.String, + "created_by": fields.Nested(simple_account_fields), + "created_at": TimestampField, + "updated_by": fields.Nested(simple_account_fields, allow_null=True), + "updated_at": TimestampField, +} + +available_evaluation_workflow_pagination_fields = { + "items": fields.List(fields.Nested(available_evaluation_workflow_list_fields)), + "page": fields.Integer, + "limit": fields.Integer, + "has_more": fields.Boolean, +} + +available_evaluation_workflow_pagination_model = console_ns.model( + "AvailableEvaluationWorkflowPagination", + available_evaluation_workflow_pagination_fields, +) + def get_evaluation_target(view_func: Callable[P, R]): """ @@ -601,6 +631,81 @@ class EvaluationVersionApi(Resource): } +@console_ns.route("/workspaces/current/available-evaluation-workflows") +class AvailableEvaluationWorkflowsApi(Resource): + @console_ns.expect(console_ns.models[WorkflowListQuery.__name__]) + @console_ns.doc("list_available_evaluation_workflows") + @console_ns.doc(description="List published evaluation workflows in the current workspace (all apps)") + @console_ns.response( + 200, + "Available evaluation workflows retrieved", + available_evaluation_workflow_pagination_model, + ) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def get(self): + """List published evaluation-type workflows for the current tenant (cross-app).""" + current_user, current_tenant_id = current_account_with_tenant() + + args = WorkflowListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + page = args.page + limit = args.limit + user_id = args.user_id + named_only = args.named_only + keyword = args.keyword + + if user_id and user_id != current_user.id: + raise Forbidden() + + workflow_service = WorkflowService() + with Session(db.engine) as session: + workflows, has_more = workflow_service.list_published_evaluation_workflows( + session=session, + tenant_id=current_tenant_id, + page=page, + limit=limit, + user_id=user_id, + named_only=named_only, + keyword=keyword, + ) + + app_ids = {w.app_id for w in workflows} + if app_ids: + apps = session.scalars(select(App).where(App.id.in_(app_ids))).all() + app_names = {a.id: a.name for a in apps} + else: + app_names = {} + + items = [] + for wf in workflows: + items.append( + { + "id": wf.id, + "app_id": wf.app_id, + "app_name": app_names.get(wf.app_id, ""), + "type": wf.type.value, + "version": wf.version, + "marked_name": wf.marked_name, + "marked_comment": wf.marked_comment, + "hash": wf.unique_hash, + "created_by": wf.created_by_account, + "created_at": wf.created_at, + "updated_by": wf.updated_by_account, + "updated_at": wf.updated_at, + } + ) + + return ( + marshal( + {"items": items, "page": page, "limit": limit, "has_more": has_more}, + available_evaluation_workflow_pagination_fields, + ), + 200, + ) + + # ---- Serialization Helpers ---- diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 7ab629d443..fb176268ff 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -5,7 +5,7 @@ import uuid from collections.abc import Callable, Generator, Mapping, Sequence from typing import Any, cast -from sqlalchemy import exists, select +from sqlalchemy import and_, exists, or_, select from sqlalchemy.orm import Session, sessionmaker from configs import dify_config @@ -58,6 +58,7 @@ from graphon.variables import VariableBase from graphon.variables.input_entities import VariableEntityType from graphon.variables.variables import Variable from libs.datetime_utils import naive_utc_now +from libs.helper import escape_like_pattern from models import Account from models.human_input import HumanInputFormRecipient, RecipientType from models.model import App, AppMode @@ -240,6 +241,59 @@ class WorkflowService: return workflows, has_more + def list_published_evaluation_workflows( + self, + *, + session: Session, + tenant_id: str, + page: int, + limit: int, + user_id: str | None, + named_only: bool = False, + keyword: str | None = None, + ) -> tuple[Sequence[Workflow], bool]: + """ + List published evaluation-type workflows for a tenant (cross-app), excluding draft rows. + + When ``keyword`` is non-empty, match workflows whose marked name or parent app name contains + the substring (case-insensitive, LIKE wildcards escaped). + """ + stmt = select(Workflow).where( + Workflow.tenant_id == tenant_id, + Workflow.type == WorkflowType.EVALUATION, + Workflow.version != Workflow.VERSION_DRAFT, + ) + + if user_id: + stmt = stmt.where(Workflow.created_by == user_id) + + if named_only: + stmt = stmt.where(Workflow.marked_name != "") + + keyword_stripped = keyword.strip() if keyword else "" + if keyword_stripped: + escaped = escape_like_pattern(keyword_stripped) + pattern = f"%{escaped}%" + stmt = stmt.join( + App, + and_(Workflow.app_id == App.id, App.tenant_id == tenant_id), + ).where( + or_( + Workflow.marked_name.ilike(pattern, escape="\\"), + App.name.ilike(pattern, escape="\\"), + ) + ) + + stmt = stmt.order_by(Workflow.created_at.desc()).limit(limit + 1).offset((page - 1) * limit) + + workflows = session.scalars(stmt).all() + + has_more = len(workflows) > limit + if has_more: + workflows = workflows[:-1] + + return workflows, has_more + def sync_draft_workflow( self, *,