mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat(web): gate /create and /refine slash commands behind feature preview flag (#37094)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a88c15c906
commit
db1aa683bc
@@ -12,6 +12,7 @@ examples accurate or the LLM will invent fields.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
# Per-node-type configuration cheatsheet.
|
||||
@@ -22,11 +23,24 @@ from typing import Any
|
||||
# both ``WorkflowService.sync_draft_workflow``'s structural checks and the
|
||||
# runtime entity validation each node performs when the workflow runs.
|
||||
#
|
||||
# The cheatsheet is assembled DYNAMICALLY per request: the planner decides
|
||||
# which node types the workflow needs, and ``build_node_config_cheatsheet``
|
||||
# stitches together only the snippets for those types (plus the always-needed
|
||||
# wrapper / shared-field / edge-handle preamble, and the containers section
|
||||
# when an iteration / loop is planned). This keeps the builder prompt tight —
|
||||
# a 3-node summariser no longer carries the schema for 12 unrelated node
|
||||
# types — and lets each snippet document its FULL schema (e.g. a "file" start
|
||||
# variable's required ``allowed_file_types``) without bloating every prompt.
|
||||
#
|
||||
# The postprocessor in ``runner.py`` fills missing wrapper fields (``type``,
|
||||
# ``positionAbsolute``, ``width``, ``height``, ``sourcePosition`` /
|
||||
# ``targetPosition``, edge ``data.sourceType`` / ``data.targetType``), so the
|
||||
# LLM only needs to emit semantically meaningful fields.
|
||||
NODE_CONFIG_CHEATSHEET = """\
|
||||
|
||||
# Always-included preamble: the node/edge wrapper shape and the shared
|
||||
# ``data`` fields that apply to every node type, plus the "## Per type" header
|
||||
# the per-type snippets slot under.
|
||||
_CHEATSHEET_PREAMBLE = """\
|
||||
## Node wrapper (every node, top-level)
|
||||
|
||||
{"id": "node1" (digits + letters only — see "Node IDs" below),
|
||||
@@ -46,14 +60,26 @@ Children of iteration / loop containers additionally need
|
||||
"desc": "<one-liner>",
|
||||
"selected": false}
|
||||
|
||||
## Per type — additional "data" fields
|
||||
## Per type — additional "data" fields (only the node types in your plan are shown)"""
|
||||
|
||||
|
||||
# node_type → its per-type schema snippet. Keyed by the exact ``node_type``
|
||||
# string the planner emits so ``build_node_config_cheatsheet`` can look each
|
||||
# one up directly. Iteration / loop are documented in the Containers section
|
||||
# (they are subgraphs, not leaf nodes) rather than here.
|
||||
_NODE_SNIPPETS: dict[str, str] = {
|
||||
"start": """\
|
||||
- start:
|
||||
{"variables": [
|
||||
{"variable": "url", "label": "URL", "type": "text-input",
|
||||
"required": true, "max_length": 256, "options": []},
|
||||
{"variable": "topic", "label": "Topic", "type": "paragraph",
|
||||
"required": false, "max_length": 4096, "options": []}
|
||||
"required": false, "max_length": 4096, "options": []},
|
||||
{"variable": "doc", "label": "Document", "type": "file",
|
||||
"required": true,
|
||||
"allowed_file_types": ["document"],
|
||||
"allowed_file_upload_methods": ["local_file", "remote_url"],
|
||||
"allowed_file_extensions": []}
|
||||
]}
|
||||
EVERY user-supplied value referenced by a downstream node
|
||||
(``{{#node-id.var#}}`` in a prompt / answer / template, or
|
||||
@@ -62,19 +88,29 @@ Children of iteration / loop containers additionally need
|
||||
If the planner's ``start_inputs`` list is non-empty, use it verbatim
|
||||
(the user prompt section "Start inputs" surfaces it). Types:
|
||||
text-input | paragraph | select | number | file | file-list.
|
||||
For a "file" or "file-list" variable you MUST also set
|
||||
``allowed_file_types`` to a NON-EMPTY subset of
|
||||
["document", "image", "audio", "video", "custom"] — it is a REQUIRED
|
||||
field and the draft fails to load (showing "supported file types is
|
||||
required") without it. Choose by purpose: ["document"] for text
|
||||
extraction (PDF / Word / PPT / Markdown / …), ["image"] for vision,
|
||||
etc. Always set ``allowed_file_upload_methods`` to
|
||||
["local_file", "remote_url"]. Only when you include "custom" must you
|
||||
also set ``allowed_file_extensions`` to a non-empty list like
|
||||
[".epub", ".rtf"]; otherwise leave it [].
|
||||
In Advanced-Chat mode ``sys.query`` and ``sys.files`` are automatic
|
||||
system variables — downstream nodes may reference them; do NOT add
|
||||
them to ``variables``.
|
||||
|
||||
them to ``variables``.""",
|
||||
"end": """\
|
||||
- end (Workflow mode only):
|
||||
{"outputs": [
|
||||
{"variable": "result", "value_selector": ["<src-node-id>", "<out-var>"]}
|
||||
]}
|
||||
|
||||
]}""",
|
||||
"answer": """\
|
||||
- answer (Advanced Chat mode only):
|
||||
{"variables": [],
|
||||
"answer": "<text with {{#<src>.<var>#}} placeholders>"}
|
||||
|
||||
"answer": "<text with {{#<src>.<var>#}} placeholders>"}""",
|
||||
"llm": """\
|
||||
- llm:
|
||||
{"model": {"provider": "<provider>", "name": "<model>", "mode": "chat",
|
||||
"completion_params": {"temperature": 0.7}},
|
||||
@@ -100,26 +136,26 @@ Children of iteration / loop containers additionally need
|
||||
values are the translations.
|
||||
Input: {{#node1.text#}}
|
||||
* Each placeholder only resolves the variable from its source node —
|
||||
it cannot be a Jinja template or call a function.
|
||||
|
||||
it cannot be a Jinja template or call a function.""",
|
||||
"knowledge-retrieval": """\
|
||||
- knowledge-retrieval:
|
||||
{"query_variable_selector": ["<src>", "<var>"],
|
||||
"query_attachment_selector": [],
|
||||
"dataset_ids": [],
|
||||
"retrieval_mode": "multiple",
|
||||
"multiple_retrieval_config": {"top_k": 4, "score_threshold": null,
|
||||
"reranking_enable": false}}
|
||||
|
||||
"reranking_enable": false}}""",
|
||||
"code": """\
|
||||
- code (escape hatch — only if no installed tool fits):
|
||||
{"code_language": "python3",
|
||||
"code": "def main(arg1: str) -> dict:\\n return {'result': arg1}",
|
||||
"variables": [{"variable": "arg1", "value_selector": ["<src>", "<var>"]}],
|
||||
"outputs": {"result": {"type": "string", "children": null}}}
|
||||
|
||||
"outputs": {"result": {"type": "string", "children": null}}}""",
|
||||
"template-transform": """\
|
||||
- template-transform:
|
||||
{"template": "Hello {{ name }}",
|
||||
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}
|
||||
|
||||
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}""",
|
||||
"http-request": """\
|
||||
- http-request (escape hatch — only if no installed tool fits):
|
||||
{"variables": [], "method": "get", "url": "https://example.com",
|
||||
"authorization": {"type": "no-auth", "config": null},
|
||||
@@ -129,8 +165,8 @@ Children of iteration / loop containers additionally need
|
||||
"timeout": {"max_connect_timeout": 0, "max_read_timeout": 0,
|
||||
"max_write_timeout": 0},
|
||||
"retry_config": {"retry_enabled": true, "max_retries": 3,
|
||||
"retry_interval": 100}}
|
||||
|
||||
"retry_interval": 100}}""",
|
||||
"tool": """\
|
||||
- tool (PREFERRED for external actions when listed in Available tools):
|
||||
{"provider_id": "<provider>", # provider portion of provider/tool
|
||||
"provider_type": "builtin", # exact value from catalogue
|
||||
@@ -144,8 +180,8 @@ Children of iteration / loop containers additionally need
|
||||
Parameter ``type`` is one of:
|
||||
"mixed" — string template referencing variables ({{#...#}})
|
||||
"variable" — direct reference, value is ["<src>", "<var>"]
|
||||
"constant" — literal value
|
||||
|
||||
"constant" — literal value""",
|
||||
"if-else": """\
|
||||
- if-else:
|
||||
{"_targetBranches": [{"id": "true", "name": "IF"},
|
||||
{"id": "false", "name": "ELSE"}],
|
||||
@@ -158,8 +194,8 @@ Children of iteration / loop containers additionally need
|
||||
"comparison_operator": "is",
|
||||
"value": "<value>"}]}
|
||||
]}
|
||||
Source handle for downstream edges = the case_id ("true" / "false").
|
||||
|
||||
Source handle for downstream edges = the case_id ("true" / "false").""",
|
||||
"question-classifier": """\
|
||||
- question-classifier:
|
||||
{"query_variable_selector": ["<src>", "<var>"],
|
||||
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
|
||||
@@ -169,8 +205,8 @@ Children of iteration / loop containers additionally need
|
||||
"_targetBranches": [{"id": "1", "name": ""}, {"id": "2", "name": ""}],
|
||||
"vision": {"enabled": false},
|
||||
"instruction": ""}
|
||||
Source handle for downstream edges = the class_id ("1" / "2" / ...).
|
||||
|
||||
Source handle for downstream edges = the class_id ("1" / "2" / ...).""",
|
||||
"parameter-extractor": """\
|
||||
- parameter-extractor:
|
||||
{"query": [["<src>", "<var>"]], # array of value_selector arrays
|
||||
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
|
||||
@@ -179,8 +215,8 @@ Children of iteration / loop containers additionally need
|
||||
"description": "<purpose>", "required": true}],
|
||||
"reasoning_mode": "prompt",
|
||||
"vision": {"enabled": false},
|
||||
"instruction": ""}
|
||||
|
||||
"instruction": ""}""",
|
||||
"document-extractor": """\
|
||||
- document-extractor:
|
||||
{"variable_selector": ["<src>", "<file-var>"], # a file / file-list input
|
||||
"is_array_file": false} # true when the input is a
|
||||
@@ -188,8 +224,9 @@ Children of iteration / loop containers additionally need
|
||||
Single output variable ``text``: a string when ``is_array_file`` is false,
|
||||
an array of strings (one per file) when it is true. ``variable_selector``
|
||||
MUST point at a ``start`` variable declared with type "file" / "file-list"
|
||||
(or ``sys.files`` in Advanced-Chat mode).
|
||||
|
||||
(or ``sys.files`` in Advanced-Chat mode). That start variable MUST set a
|
||||
non-empty ``allowed_file_types`` (use ["document"] for document text).""",
|
||||
"variable-aggregator": """\
|
||||
- variable-aggregator (merge mutually-exclusive branches into one output):
|
||||
{"output_type": "string", # VarType of the merged value — one of
|
||||
# string | number | object | array[string] |
|
||||
@@ -200,8 +237,8 @@ Children of iteration / loop containers additionally need
|
||||
Output variable: ``output`` (the first branch that actually ran). Place it
|
||||
after an ``if-else`` / ``question-classifier`` to rejoin paths before the
|
||||
``end`` / ``answer`` node. Each entry of ``variables`` is a value_selector
|
||||
array, NOT a placeholder string.
|
||||
|
||||
array, NOT a placeholder string.""",
|
||||
"list-operator": """\
|
||||
- list-operator (filter / sort / slice an array variable):
|
||||
{"variable": ["<src>", "<array-var>"],
|
||||
"filter_by": {"enabled": false, "conditions": []},
|
||||
@@ -210,8 +247,12 @@ Children of iteration / loop containers additionally need
|
||||
"limit": {"enabled": false, "size": 10}}
|
||||
Enable only the sub-features you need; ``conditions`` reuse the if-else
|
||||
condition shape (key / comparison_operator / value). Outputs: ``result``
|
||||
(the processed array), ``first_record``, ``last_record``.
|
||||
(the processed array), ``first_record``, ``last_record``.""",
|
||||
}
|
||||
|
||||
|
||||
# Pulled into the cheatsheet only when an iteration / loop appears in the plan.
|
||||
_CONTAINERS_SECTION = """\
|
||||
## Containers — iteration / loop
|
||||
|
||||
These are SUBGRAPH nodes. To use one you MUST emit, in order:
|
||||
@@ -270,16 +311,59 @@ These are SUBGRAPH nodes. To use one you MUST emit, in order:
|
||||
|
||||
5. The container's incoming/outgoing edges connect to the container's id
|
||||
(``nodeK``), NOT to inner nodes. The first inner edge connects from
|
||||
``nodeKstart``.
|
||||
``nodeKstart``."""
|
||||
|
||||
|
||||
# Always-included trailer: edge handle conventions for every graph.
|
||||
_EDGE_HANDLES_SECTION = """\
|
||||
## Edge handles
|
||||
|
||||
- Most nodes: sourceHandle "source", targetHandle "target".
|
||||
- if-else cases: sourceHandle is the case_id ("true" / "false" / ...).
|
||||
- question-classifier: sourceHandle is the class_id ("1" / "2" / ...).
|
||||
- iteration-start / sourceHandle "source"; the edge from the *start node
|
||||
loop-start: is what kicks off the first inner step.
|
||||
"""
|
||||
loop-start: is what kicks off the first inner step."""
|
||||
|
||||
|
||||
# Container node types are described in ``_CONTAINERS_SECTION`` rather than as
|
||||
# leaf snippets; their presence in a plan pulls that section in.
|
||||
_CONTAINER_NODE_TYPES = frozenset({"iteration", "loop"})
|
||||
|
||||
|
||||
def build_node_config_cheatsheet(node_types: Iterable[str] | None = None) -> str:
|
||||
"""
|
||||
Assemble the builder cheatsheet for exactly the node types in the plan.
|
||||
|
||||
``node_types`` is the set of ``node_type`` strings the planner chose. We
|
||||
emit the always-on preamble (wrapper / shared fields), then only the
|
||||
per-type snippets for the requested types (``start`` is always included —
|
||||
every graph has one), the Containers section when an iteration / loop is
|
||||
planned, and the edge-handles trailer. Unknown / unrecognised type strings
|
||||
are ignored (the runtime / structural validator catches genuinely bogus
|
||||
types).
|
||||
|
||||
``None`` returns the FULL cheatsheet (every snippet + containers) — used to
|
||||
build the static back-compat constants below and as a safe fallback.
|
||||
"""
|
||||
if node_types is None:
|
||||
requested: set[str] = set(_NODE_SNIPPETS) | set(_CONTAINER_NODE_TYPES)
|
||||
else:
|
||||
requested = {str(t).strip() for t in node_types if str(t).strip()}
|
||||
requested.add("start") # every workflow has exactly one start node
|
||||
|
||||
parts: list[str] = [_CHEATSHEET_PREAMBLE]
|
||||
# Iterate _NODE_SNIPPETS (not ``requested``) to keep a stable, readable order.
|
||||
parts.extend(snippet for node_type, snippet in _NODE_SNIPPETS.items() if node_type in requested)
|
||||
if requested & _CONTAINER_NODE_TYPES:
|
||||
parts.append(_CONTAINERS_SECTION)
|
||||
parts.append(_EDGE_HANDLES_SECTION)
|
||||
return "\n\n".join(parts) + "\n"
|
||||
|
||||
|
||||
# Full cheatsheet (all node types) — retained as a module constant so callers
|
||||
# and tests that want the complete reference can import it directly. The
|
||||
# dynamic per-request prompt is built by ``get_builder_system_prompt``.
|
||||
NODE_CONFIG_CHEATSHEET = build_node_config_cheatsheet()
|
||||
|
||||
|
||||
_BASE_SYSTEM_PROMPT_HEAD = """You are a Dify workflow builder.
|
||||
@@ -402,21 +486,24 @@ _ADVANCED_CHAT_MODE_RULES = """# Mode-specific rules — Advanced Chat (Chatflow
|
||||
"""
|
||||
|
||||
|
||||
BUILDER_SYSTEM_PROMPT_WORKFLOW = (
|
||||
_BASE_SYSTEM_PROMPT_HEAD
|
||||
+ _WORKFLOW_MODE_RULES
|
||||
+ _BASE_SYSTEM_PROMPT_TAIL
|
||||
+ NODE_CONFIG_CHEATSHEET
|
||||
+ _BASE_SYSTEM_PROMPT_FOOTER
|
||||
)
|
||||
def _assemble_builder_system_prompt(mode: str, node_types: Iterable[str] | None) -> str:
|
||||
"""Stitch the builder system prompt for ``mode`` around a cheatsheet built
|
||||
for ``node_types`` (``None`` → full cheatsheet)."""
|
||||
mode_rules = _ADVANCED_CHAT_MODE_RULES if mode == "advanced-chat" else _WORKFLOW_MODE_RULES
|
||||
return (
|
||||
_BASE_SYSTEM_PROMPT_HEAD
|
||||
+ mode_rules
|
||||
+ _BASE_SYSTEM_PROMPT_TAIL
|
||||
+ build_node_config_cheatsheet(node_types)
|
||||
+ _BASE_SYSTEM_PROMPT_FOOTER
|
||||
)
|
||||
|
||||
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = (
|
||||
_BASE_SYSTEM_PROMPT_HEAD
|
||||
+ _ADVANCED_CHAT_MODE_RULES
|
||||
+ _BASE_SYSTEM_PROMPT_TAIL
|
||||
+ NODE_CONFIG_CHEATSHEET
|
||||
+ _BASE_SYSTEM_PROMPT_FOOTER
|
||||
)
|
||||
|
||||
# Static full-cheatsheet prompts — the back-compat default returned by
|
||||
# ``get_builder_system_prompt`` when the caller doesn't pin a node-type set.
|
||||
BUILDER_SYSTEM_PROMPT_WORKFLOW = _assemble_builder_system_prompt("workflow", None)
|
||||
|
||||
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = _assemble_builder_system_prompt("advanced-chat", None)
|
||||
|
||||
|
||||
BUILDER_USER_PROMPT = """# User instruction
|
||||
@@ -546,8 +633,16 @@ def format_plan_block(plan_nodes: list[dict[str, Any]]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_builder_system_prompt(mode: str) -> str:
|
||||
"""Pick the system prompt branch for Workflow vs Advanced Chat."""
|
||||
if mode == "advanced-chat":
|
||||
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT
|
||||
return BUILDER_SYSTEM_PROMPT_WORKFLOW
|
||||
def get_builder_system_prompt(mode: str, node_types: Iterable[str] | None = None) -> str:
|
||||
"""
|
||||
Build the builder system prompt for ``mode``, with a cheatsheet scoped to
|
||||
``node_types`` (the planner's chosen node types).
|
||||
|
||||
When ``node_types`` is ``None`` we return the cached full-cheatsheet
|
||||
constant (back-compat default). When the runner passes the plan's node-type
|
||||
set we assemble a fresh prompt carrying only the relevant per-type schemas,
|
||||
so the builder isn't handed config for node types the workflow never uses.
|
||||
"""
|
||||
if node_types is None:
|
||||
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT if mode == "advanced-chat" else BUILDER_SYSTEM_PROMPT_WORKFLOW
|
||||
return _assemble_builder_system_prompt(mode, node_types)
|
||||
|
||||
@@ -74,6 +74,21 @@ _DEFAULT_VIEWPORT: GraphViewportDict = {"x": 0.0, "y": 0.0, "zoom": 0.7}
|
||||
_DEFAULT_NODE_WIDTH = 244
|
||||
_DEFAULT_NODE_HEIGHT = 100
|
||||
|
||||
# Start-node input variable types that carry file uploads. Mirrors
|
||||
# ``graphon.variables.input_entities.VariableEntityType.FILE / FILE_LIST``.
|
||||
_FILE_VARIABLE_TYPES = frozenset({"file", "file-list"})
|
||||
|
||||
# Backstop defaults for a file / file-list start variable when the builder
|
||||
# omits the required upload config. ``allowed_file_types`` is a REQUIRED field
|
||||
# (Studio rejects the draft with "supported file types is required" when it's
|
||||
# empty — see ``config-var/config-modal/utils.ts``); we default to every
|
||||
# standard type so no valid upload is rejected. ``custom`` is intentionally
|
||||
# excluded because it would in turn require a non-empty
|
||||
# ``allowed_file_extensions``. The real fix is the builder now documenting and
|
||||
# emitting these fields; this is the safety net that guarantees a loadable draft.
|
||||
_DEFAULT_ALLOWED_FILE_TYPES = ("document", "image", "audio", "video")
|
||||
_DEFAULT_FILE_UPLOAD_METHODS = ("local_file", "remote_url")
|
||||
|
||||
# Token ceiling for the planner call when the caller didn't pin one. The plan
|
||||
# is a short JSON node list (a handful of nodes with labels/purposes), so this
|
||||
# is generous headroom while still bounding a runaway response. The builder is
|
||||
@@ -512,8 +527,15 @@ class WorkflowGenerator:
|
||||
tool_catalogue_section=format_builder_tool_catalogue_section(tool_catalogue_text),
|
||||
start_inputs_section=format_start_inputs_section(start_inputs or []),
|
||||
)
|
||||
# Scope the builder cheatsheet to exactly the node types the planner
|
||||
# chose, so the prompt carries each type's FULL schema (e.g. a file
|
||||
# start variable's required ``allowed_file_types``) without dragging in
|
||||
# config for unrelated node types.
|
||||
plan_node_types = {
|
||||
str(node.get("node_type") or "").strip() for node in plan_nodes if str(node.get("node_type") or "").strip()
|
||||
}
|
||||
messages = [
|
||||
SystemPromptMessage(content=get_builder_system_prompt(mode)),
|
||||
SystemPromptMessage(content=get_builder_system_prompt(mode, plan_node_types)),
|
||||
UserPromptMessage(content=user_prompt),
|
||||
]
|
||||
parsed = cls._invoke_and_parse_json(
|
||||
@@ -658,6 +680,13 @@ class WorkflowGenerator:
|
||||
# variables before we surface them as errors.
|
||||
cls._reconcile_variable_references(nodes=nodes, mode=mode)
|
||||
|
||||
# Schema backstop: a "file" / "file-list" start variable MUST carry a
|
||||
# non-empty ``allowed_file_types`` or Studio refuses to load the draft
|
||||
# ("supported file types is required"). The builder is now told to set
|
||||
# it, but we fill safe defaults for any variable that still lacks it so
|
||||
# the generated workflow always loads and runs.
|
||||
cls._normalize_start_file_variables(nodes=nodes)
|
||||
|
||||
return cast(GraphDict, {"nodes": nodes, "edges": deduped_edges, "viewport": viewport})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -693,6 +722,21 @@ class WorkflowGenerator:
|
||||
# remapping when we defensively strip hyphens out of LLM-emitted ids.
|
||||
_ID_FIELDS: ClassVar = frozenset({"start_node_id", "iteration_id", "loop_id", "parentId"})
|
||||
|
||||
# ``data`` keys whose value is a plain string list, never a
|
||||
# ``[node_id, var]`` value-selector — so the reference walker must not read
|
||||
# a 2-element one as a selector. ``default`` holds an input's default value;
|
||||
# ``options`` holds select choices; the ``allowed_file_*`` keys hold a file
|
||||
# variable's upload config (types / extensions / methods).
|
||||
_NON_SELECTOR_LIST_KEYS: ClassVar = frozenset(
|
||||
{
|
||||
"default",
|
||||
"options",
|
||||
"allowed_file_types",
|
||||
"allowed_file_extensions",
|
||||
"allowed_file_upload_methods",
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _reconcile_variable_references(cls, *, nodes: list[dict[str, Any]], mode: WorkflowGenerationMode) -> None:
|
||||
"""
|
||||
@@ -747,12 +791,16 @@ class WorkflowGenerator:
|
||||
# Known selector shapes: 2-element [node_id, var] lists.
|
||||
for k, v in value.items():
|
||||
# ``value_selector`` / ``query_variable_selector`` / etc.: a
|
||||
# flat 2-element list of strings.
|
||||
# flat 2-element list of strings. Skip keys whose value is a
|
||||
# plain string list that merely HAPPENS to have two entries —
|
||||
# a 2-option ``select`` or a file variable's two allowed upload
|
||||
# methods are NOT ``[node_id, var]`` selectors and must not be
|
||||
# mistaken for references.
|
||||
if (
|
||||
isinstance(v, list)
|
||||
and len(v) == 2
|
||||
and all(isinstance(x, str) for x in v)
|
||||
and k != "default" # default values for input variables are not selectors
|
||||
and k not in cls._NON_SELECTOR_LIST_KEYS
|
||||
):
|
||||
node_id, var = v[0].strip(), v[1].strip()
|
||||
if node_id and var:
|
||||
@@ -923,6 +971,96 @@ class WorkflowGenerator:
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _normalize_start_file_variables(cls, *, nodes: list[dict[str, Any]]) -> None:
|
||||
"""
|
||||
Fill the required upload config on every file / file-list start variable.
|
||||
|
||||
A start variable of type ``file`` / ``file-list`` is invalid without a
|
||||
non-empty ``allowed_file_types`` — Studio rejects the draft with
|
||||
"supported file types is required" (see the front-end validator in
|
||||
``config-var/config-modal/utils.ts``) and the workflow never runs. The
|
||||
builder prompt now documents these fields, but LLMs still drop them, so
|
||||
we backfill safe defaults here:
|
||||
|
||||
* a start variable a ``document-extractor`` consumes but that wasn't
|
||||
declared as a file type → promoted to ``file`` (or ``file-list``
|
||||
when the extractor's ``is_array_file`` is set), defaulting its
|
||||
allowed types to ``["document"]`` (what extraction needs);
|
||||
* empty / missing ``allowed_file_types`` → every standard file type;
|
||||
* ``custom`` present without ``allowed_file_extensions`` → drop
|
||||
``custom`` (it would otherwise require a non-empty extension list);
|
||||
* empty / missing ``allowed_file_upload_methods`` → local + remote;
|
||||
* ensure ``allowed_file_extensions`` is at least an empty list.
|
||||
|
||||
Idempotent: a variable that already declares valid file config is left
|
||||
untouched.
|
||||
"""
|
||||
start_node = next(
|
||||
(n for n in nodes if (n.get("data") or {}).get("type") == BuiltinNodeTypes.START),
|
||||
None,
|
||||
)
|
||||
if start_node is None:
|
||||
return
|
||||
variables = (start_node.get("data") or {}).get("variables")
|
||||
if not isinstance(variables, list):
|
||||
return
|
||||
|
||||
# Start variables a document-extractor reads → whether it wants an
|
||||
# array (file-list). These MUST be file inputs even if the builder
|
||||
# mistyped them (e.g. declared "paragraph"), or the extractor fails at
|
||||
# run time. ``["document"]`` is the right default for text extraction.
|
||||
extractor_file_vars = cls._document_extractor_start_vars(nodes=nodes, start_id=start_node.get("id", ""))
|
||||
|
||||
for var in variables:
|
||||
if not isinstance(var, dict):
|
||||
continue
|
||||
name = var.get("variable")
|
||||
if name in extractor_file_vars and var.get("type") not in _FILE_VARIABLE_TYPES:
|
||||
var["type"] = "file-list" if extractor_file_vars[name] else "file"
|
||||
var.setdefault("allowed_file_types", ["document"])
|
||||
if var.get("type") not in _FILE_VARIABLE_TYPES:
|
||||
continue
|
||||
allowed_types = var.get("allowed_file_types")
|
||||
if not isinstance(allowed_types, list) or not allowed_types:
|
||||
allowed_types = list(_DEFAULT_ALLOWED_FILE_TYPES)
|
||||
var["allowed_file_types"] = allowed_types
|
||||
# ``custom`` demands a non-empty extension list; without one, drop it
|
||||
# so the variable doesn't trip the "file extensions required" check.
|
||||
extensions = var.get("allowed_file_extensions")
|
||||
has_extensions = isinstance(extensions, list) and bool(extensions)
|
||||
if "custom" in allowed_types and not has_extensions:
|
||||
pruned = [t for t in allowed_types if t != "custom"]
|
||||
var["allowed_file_types"] = pruned or list(_DEFAULT_ALLOWED_FILE_TYPES)
|
||||
methods = var.get("allowed_file_upload_methods")
|
||||
if not isinstance(methods, list) or not methods:
|
||||
var["allowed_file_upload_methods"] = list(_DEFAULT_FILE_UPLOAD_METHODS)
|
||||
if not isinstance(var.get("allowed_file_extensions"), list):
|
||||
var["allowed_file_extensions"] = []
|
||||
|
||||
@classmethod
|
||||
def _document_extractor_start_vars(cls, *, nodes: list[dict[str, Any]], start_id: str) -> dict[str, bool]:
|
||||
"""
|
||||
Map start-variable name → ``is_array_file`` for every start variable a
|
||||
``document-extractor`` node reads via its ``variable_selector``.
|
||||
|
||||
When two extractors read the same variable we keep ``True`` (file-list)
|
||||
if any of them wants an array, since a file-list also satisfies a
|
||||
single-file read.
|
||||
"""
|
||||
out: dict[str, bool] = {}
|
||||
if not start_id:
|
||||
return out
|
||||
for node in nodes:
|
||||
data = node.get("data") or {}
|
||||
if data.get("type") != BuiltinNodeTypes.DOCUMENT_EXTRACTOR:
|
||||
continue
|
||||
selector = data.get("variable_selector")
|
||||
if isinstance(selector, list) and len(selector) == 2 and selector[0] == start_id:
|
||||
var_name = selector[1]
|
||||
out[var_name] = out.get(var_name, False) or bool(data.get("is_array_file"))
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def _fill_node_defaults(cls, node: dict[str, Any]) -> None:
|
||||
"""Ensure every node has the wrapper-level fields the Studio canvas needs."""
|
||||
|
||||
Reference in New Issue
Block a user