mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(workflow-generator): enhance the AI auto-creation flow end-to-end (#38175)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
85 files changed
+3386
-500
No files matched your search
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
@@ -458,3 +459,214 @@ def test_workflow_generate_current_graph_defaults_to_none(app: Flask, monkeypatc
|
||||
method(api, "t1")
|
||||
|
||||
assert captured["current_graph"] is None
|
||||
|
||||
|
||||
def test_workflow_generate_accepts_auto_mode(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 3: the payload Literal must accept ``auto``; the controller forwards
|
||||
it unchanged (the service resolves it) and returns the resolved ``mode``."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
"mode": "advanced-chat",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
|
||||
|
||||
payload = _workflow_generate_payload()
|
||||
payload["mode"] = "auto"
|
||||
with app.test_request_context("/console/api/workflow-generate", method="POST", json=payload):
|
||||
response = method(api, "t1")
|
||||
|
||||
assert captured["mode"] == "auto"
|
||||
assert response["mode"] == "advanced-chat"
|
||||
|
||||
|
||||
# ─ /workflow-generate/suggestions ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_generate_instruction_suggestions_parses_and_cleans(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 1c (i): a mocked default model returning a JSON array is parsed + cleaned."""
|
||||
from core.llm_generator import llm_generator as llm_gen_module
|
||||
|
||||
instance = MagicMock()
|
||||
instance.invoke_llm.return_value.message.get_text_content.return_value = '["Summarize a URL", "Translate text"]'
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance
|
||||
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
|
||||
monkeypatch.setattr(llm_gen_module.LLMGenerator, "_build_suggestion_context", staticmethod(lambda _tenant: ""))
|
||||
|
||||
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(
|
||||
tenant_id="t1", mode="workflow", count=4
|
||||
)
|
||||
|
||||
assert result == ["Summarize a URL", "Translate text"]
|
||||
|
||||
|
||||
def test_generate_instruction_suggestions_dedupes_and_caps(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Whitespace / surrounding quotes are stripped, case-insensitive dupes dropped, capped to count."""
|
||||
from core.llm_generator import llm_generator as llm_gen_module
|
||||
|
||||
instance = MagicMock()
|
||||
instance.invoke_llm.return_value.message.get_text_content.return_value = (
|
||||
'[" Summarize a URL ", "summarize a URL", "\'Translate text\'", "Draft an email", "Extra idea"]'
|
||||
)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance
|
||||
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
|
||||
monkeypatch.setattr(llm_gen_module.LLMGenerator, "_build_suggestion_context", staticmethod(lambda _tenant: ""))
|
||||
|
||||
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(
|
||||
tenant_id="t1", mode="advanced-chat", count=3
|
||||
)
|
||||
|
||||
assert result == ["Summarize a URL", "Translate text", "Draft an email"]
|
||||
|
||||
|
||||
def test_generate_instruction_suggestions_no_default_model_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 1c (ii): a missing default model degrades to an empty list, never raising."""
|
||||
from core.llm_generator import llm_generator as llm_gen_module
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.for_tenant.return_value.get_default_model_instance.side_effect = ProviderTokenNotInitError(
|
||||
"no default model"
|
||||
)
|
||||
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
|
||||
|
||||
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(tenant_id="t1", mode="workflow")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_workflow_instruction_suggestions_route_returns_list(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 1c (iii): the route wraps the generator output in {"suggestions": [...]}."""
|
||||
api = generator_module.WorkflowInstructionSuggestionsApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _suggest(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return ["Summarize a URL", "Translate text"]
|
||||
|
||||
monkeypatch.setattr(generator_module.LLMGenerator, "generate_workflow_instruction_suggestions", _suggest)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate/suggestions",
|
||||
method="POST",
|
||||
json={"mode": "workflow", "language": "French", "count": 3},
|
||||
):
|
||||
response = method(api, "t1")
|
||||
|
||||
assert response == {"suggestions": ["Summarize a URL", "Translate text"]}
|
||||
assert captured["mode"] == "workflow"
|
||||
assert captured["language"] == "French"
|
||||
assert captured["count"] == 3
|
||||
|
||||
|
||||
def test_workflow_instruction_suggestions_route_empty_is_valid_200(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 1c (iii): an empty list is a valid soft-fail response."""
|
||||
api = generator_module.WorkflowInstructionSuggestionsApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
monkeypatch.setattr(
|
||||
generator_module.LLMGenerator,
|
||||
"generate_workflow_instruction_suggestions",
|
||||
lambda **_kwargs: [],
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate/suggestions",
|
||||
method="POST",
|
||||
json={"mode": "advanced-chat"},
|
||||
):
|
||||
response = method(api, "t1")
|
||||
|
||||
assert response == {"suggestions": []}
|
||||
|
||||
|
||||
# ─ /workflow-generate/stream ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_sse_frames(response) -> list[dict]:
|
||||
"""Decode an SSE Response body into its parsed ``data:`` JSON frames."""
|
||||
body = response.get_data(as_text=True)
|
||||
frames = []
|
||||
for chunk in body.strip().split("\n\n"):
|
||||
chunk = chunk.strip()
|
||||
if chunk.startswith("data: "):
|
||||
frames.append(json.loads(chunk[len("data: ") :]))
|
||||
return frames
|
||||
|
||||
|
||||
def test_workflow_generate_stream_emits_plan_then_result(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 2c: the stream endpoint writes one SSE frame per service event."""
|
||||
api = generator_module.WorkflowGenerateStreamApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
def _stream(**_kwargs):
|
||||
yield ("plan", {"title": "Summarizer", "mode": "workflow", "nodes": []})
|
||||
yield ("result", {"graph": {"nodes": []}, "error": "", "mode": "workflow"})
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph_stream", _stream)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate/stream",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
response = method(api, "t1")
|
||||
assert response.mimetype == "text/event-stream"
|
||||
frames = _read_sse_frames(response)
|
||||
|
||||
assert [f["event"] for f in frames] == ["plan", "result"]
|
||||
assert frames[0]["title"] == "Summarizer"
|
||||
assert frames[1]["mode"] == "workflow"
|
||||
|
||||
|
||||
def test_workflow_generate_stream_provider_error_emits_result_event(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Task 2c: a provider-init error becomes a single MODEL_ERROR result frame, not a non-SSE error."""
|
||||
api = generator_module.WorkflowGenerateStreamApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
def _stream(**_kwargs):
|
||||
raise ProviderTokenNotInitError("missing token")
|
||||
yield # pragma: no cover - marks this a generator
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph_stream", _stream)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate/stream",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
response = method(api, "t1")
|
||||
frames = _read_sse_frames(response)
|
||||
|
||||
assert len(frames) == 1
|
||||
assert frames[0]["event"] == "result"
|
||||
assert frames[0]["errors"][0]["code"] == "MODEL_ERROR"
|
||||
assert frames[0]["graph"]["nodes"] == []
|
||||
|
||||
|
||||
def test_workflow_generate_stream_rejects_empty_instruction(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 2c: empty instructions get a normal 400 JSON BEFORE the stream opens."""
|
||||
api = generator_module.WorkflowGenerateStreamApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = _workflow_generate_payload()
|
||||
payload["instruction"] = " "
|
||||
with app.test_request_context("/console/api/workflow-generate/stream", method="POST", json=payload):
|
||||
response, status = method(api, "t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["errors"][0]["code"] == "EMPTY_INSTRUCTION"
|
||||
@@ -0,0 +1,138 @@
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.app import generator as generator_module
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
|
||||
|
||||
def unwrap(func):
|
||||
"""Unwrap a decorated function to test it directly."""
|
||||
while hasattr(func, "__wrapped__"):
|
||||
func = func.__wrapped__
|
||||
return func
|
||||
|
||||
|
||||
def _model_config_payload():
|
||||
return {
|
||||
"provider": "test_provider",
|
||||
"name": "test_model",
|
||||
"mode": "chat",
|
||||
"completion_params": {},
|
||||
}
|
||||
|
||||
|
||||
def test_rule_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.RuleGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
exceptions_to_test = [
|
||||
(ProviderTokenNotInitError("token error"), generator_module.ProviderNotInitializeError),
|
||||
(QuotaExceededError("quota error"), generator_module.ProviderQuotaExceededError),
|
||||
(ModelCurrentlyNotSupportError("model error"), generator_module.ProviderModelCurrentlyNotSupportError),
|
||||
(InvokeError("invoke error"), generator_module.CompletionRequestError),
|
||||
]
|
||||
|
||||
for err_to_raise, expected_exception in exceptions_to_test:
|
||||
|
||||
def _raise(*_args, _err=err_to_raise, **_kwargs):
|
||||
raise _err
|
||||
|
||||
monkeypatch.setattr(generator_module.LLMGenerator, "generate_rule_config", _raise)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/rule-generate",
|
||||
method="POST",
|
||||
json={"instruction": "do it", "model_config": _model_config_payload()},
|
||||
):
|
||||
with pytest.raises(expected_exception):
|
||||
method(api, "t1")
|
||||
|
||||
|
||||
def test_rule_code_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.RuleCodeGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
exceptions_to_test = [
|
||||
(QuotaExceededError("quota error"), generator_module.ProviderQuotaExceededError),
|
||||
(ModelCurrentlyNotSupportError("model error"), generator_module.ProviderModelCurrentlyNotSupportError),
|
||||
(InvokeError("invoke error"), generator_module.CompletionRequestError),
|
||||
]
|
||||
|
||||
for err_to_raise, expected_exception in exceptions_to_test:
|
||||
|
||||
def _raise(*_args, _err=err_to_raise, **_kwargs):
|
||||
raise _err
|
||||
|
||||
monkeypatch.setattr(generator_module.LLMGenerator, "generate_code", _raise)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/rule-code-generate",
|
||||
method="POST",
|
||||
json={"instruction": "do it", "model_config": _model_config_payload()},
|
||||
):
|
||||
with pytest.raises(expected_exception):
|
||||
method(api, "t1")
|
||||
|
||||
|
||||
def test_structured_output_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.RuleStructuredOutputGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
exceptions_to_test = [
|
||||
(ProviderTokenNotInitError("token error"), generator_module.ProviderNotInitializeError),
|
||||
(QuotaExceededError("quota error"), generator_module.ProviderQuotaExceededError),
|
||||
(ModelCurrentlyNotSupportError("model error"), generator_module.ProviderModelCurrentlyNotSupportError),
|
||||
(InvokeError("invoke error"), generator_module.CompletionRequestError),
|
||||
]
|
||||
|
||||
for err_to_raise, expected_exception in exceptions_to_test:
|
||||
|
||||
def _raise(*_args, _err=err_to_raise, **_kwargs):
|
||||
raise _err
|
||||
|
||||
monkeypatch.setattr(generator_module.LLMGenerator, "generate_structured_output", _raise)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/structured-output-generate",
|
||||
method="POST",
|
||||
json={"instruction": "do it", "model_config": _model_config_payload()},
|
||||
):
|
||||
with pytest.raises(expected_exception):
|
||||
method(api, "t1")
|
||||
|
||||
|
||||
def test_instruction_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
from types import SimpleNamespace
|
||||
|
||||
session = SimpleNamespace()
|
||||
|
||||
exceptions_to_test = [
|
||||
(ProviderTokenNotInitError("token error"), generator_module.ProviderNotInitializeError),
|
||||
(QuotaExceededError("quota error"), generator_module.ProviderQuotaExceededError),
|
||||
(ModelCurrentlyNotSupportError("model error"), generator_module.ProviderModelCurrentlyNotSupportError),
|
||||
(InvokeError("invoke error"), generator_module.CompletionRequestError),
|
||||
]
|
||||
|
||||
for err_to_raise, expected_exception in exceptions_to_test:
|
||||
|
||||
def _raise(*_args, _err=err_to_raise, **_kwargs):
|
||||
raise _err
|
||||
|
||||
monkeypatch.setattr(generator_module.LLMGenerator, "instruction_modify_legacy", _raise)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/instruction-generate",
|
||||
method="POST",
|
||||
json={
|
||||
"flow_id": "app-1",
|
||||
"node_id": "",
|
||||
"current": "old",
|
||||
"instruction": "do it",
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
with pytest.raises(expected_exception):
|
||||
method(api, session, "t1")
|
||||
Reference in new issue
Block a user