From aa2b4fe4ab5886e63cfe2aa341cb5371f42e5c80 Mon Sep 17 00:00:00 2001 From: Charles Yao Date: Thu, 17 Sep 2026 17:55:38 -0700 Subject: [PATCH] feat(dify-builder): name apps from their prompt, and turn gate actions into card options Two App Builder spec changes that share the Builder session contract. Naming (spec N1-N3): an app created from the homepage prompt is no longer named by the user. core/dify_builder/naming.py cuts the prompt's opening clause at create time -- instant and infallible, since it runs inside POST /apps -- and CreateAppPayload.name becomes optional alongside a new prompt field. The Builder then refines that name once during goal analysis, where a model and the detected reply language are already in hand, so the judgement calls that are English-shaped in pure Python (dropping an imperative lead-in, spotting a contentless prompt) work in any language. Plan approval freezes the name. Card options: SessionView gains a Decision carrying the gate's choice as options inside the card plus a fixed confirm/cancel pair, replacing a button row whose shape changed at every state. Option ids are the old action ids untouched, so resolve_action_kind and the per-state legality check are unchanged, and SessionView.actions keeps being populated -- the deployed web client goes on working until it reads decision instead. The regenerated console OpenAPI doc also picks up ErrorCard.diagnostics, missed when cc9ca8b1dd landed. Co-Authored-By: Claude Opus 5 (1M context) --- api/controllers/console/app/app.py | 9 +- api/controllers/console/dify_builder.py | 5 +- .../console/dify_builder_fields.py | 6 + api/core/dify_builder/contract.py | 66 ++++++++ api/core/dify_builder/handlers_build.py | 25 +++ api/core/dify_builder/models.py | 4 + api/core/dify_builder/naming.py | 150 ++++++++++++++++++ api/core/dify_builder/placeholder_agent.py | 5 + api/core/dify_builder/ports.py | 4 + api/core/dify_builder/runner.py | 5 + api/openapi/markdown/console-openapi.md | 39 ++++- api/services/app_service.py | 51 +++++- api/services/dify_builder/agent/build.py | 43 +++++ api/services/dify_builder/agent/llm_agent.py | 8 + api/services/dify_builder/app_naming.py | 46 ++++++ api/services/dify_builder/serde.py | 1 + api/services/dify_builder/service.py | 102 +++++++++++- api/tasks/dify_builder_advance_task.py | 14 +- .../console/app/test_create_app_payload.py | 21 +++ .../console/test_dify_builder_routes.py | 63 +++++++- .../unit_tests/core/dify_builder/fakes.py | 3 + .../core/dify_builder/test_handlers_build.py | 65 ++++++++ .../core/dify_builder/test_naming.py | 113 +++++++++++++ .../core/dify_builder/test_ports.py | 3 + .../services/dify_builder/test_app_naming.py | 80 ++++++++++ .../dify_builder/test_card_options.py | 137 ++++++++++++++++ .../dify_builder/test_serde_app_name_auto.py | 19 +++ .../services/dify_builder/test_service.py | 47 ++++++ .../services/dify_builder/test_wiring.py | 1 + .../unit_tests/services/test_app_service.py | 46 ++++++ 30 files changed, 1163 insertions(+), 18 deletions(-) create mode 100644 api/core/dify_builder/naming.py create mode 100644 api/services/dify_builder/app_naming.py create mode 100644 api/tests/unit_tests/core/dify_builder/test_naming.py create mode 100644 api/tests/unit_tests/services/dify_builder/test_app_naming.py create mode 100644 api/tests/unit_tests/services/dify_builder/test_card_options.py create mode 100644 api/tests/unit_tests/services/dify_builder/test_serde_app_name_auto.py diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 28baa5b01a5..21d71e6dc95 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -156,7 +156,13 @@ class StarredAppListQuery(AppListBaseQuery): class CreateAppPayload(BaseModel): - name: str = Field(..., min_length=1, description="App name") + # Optional since App Builder: an app created from the homepage prompt sends + # ``prompt`` and no ``name``. A supplied name still wins -- that is + # create-from-blank, where the user names the app and Builder inherits it. + name: str | None = Field(default=None, min_length=1, description="App name; derived from prompt when omitted") + prompt: str | None = Field( + default=None, description="Goal prompt the app was created from; used to derive the name" + ) description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400) mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = Field(..., description="App mode") icon_type: IconType | None = Field(default=None, description="Icon type") @@ -708,6 +714,7 @@ class AppListApi(Resource): """Create app""" params = CreateAppParams( name=req_data.name, + prompt=req_data.prompt, description=req_data.description, mode=req_data.mode, icon_type=req_data.icon_type, diff --git a/api/controllers/console/dify_builder.py b/api/controllers/console/dify_builder.py index 1497c40e29d..bf628bc2fc3 100644 --- a/api/controllers/console/dify_builder.py +++ b/api/controllers/console/dify_builder.py @@ -52,7 +52,7 @@ from core.dify_builder.models import Action, Actor from libs.helper import dump_response from libs.login import login_required from services.dify_builder import progress_bus -from services.dify_builder.service import resolve_action_kind +from services.dify_builder.service import resolve_submitted_action from services.dify_builder.wiring import ( build_service, dify_builder_error_response, @@ -140,6 +140,7 @@ def _create(body, actor: Actor) -> Response | tuple[dict, int]: actor=actor, goal_text=payload.goal_text, model_config=model_config, + derive_app_name=payload.derive_app_name, ) ) if isinstance(payload, DifyBuilderCreateEditSessionPayload): @@ -179,7 +180,7 @@ def _action(session_id: str, body, actor: Actor) -> Response | tuple[dict, int]: except ValidationError: return {"code": "bad_request"}, 400 action = Action( - kind=resolve_action_kind(payload.action_id), + kind=resolve_submitted_action(payload.action_id, payload.payload), payload=payload.payload, base_version=payload.base_version, base_app_revision=payload.base_app_revision, diff --git a/api/controllers/console/dify_builder_fields.py b/api/controllers/console/dify_builder_fields.py index e3016f48929..72474787faf 100644 --- a/api/controllers/console/dify_builder_fields.py +++ b/api/controllers/console/dify_builder_fields.py @@ -16,6 +16,7 @@ from core.dify_builder.contract import ( ChangeSetCard, CheckpointCard, CheckpointRef, + Decision, DecisionItem, ErrorCard, ErrorEventData, @@ -52,6 +53,10 @@ class DifyBuilderCreateBuildSessionPayload(DifyBuilderPayload): app_id: NonEmptyString scenario: Literal["build"] goal_text: NonEmptyString + # True when the app was created from this same prompt and still carries the + # name cut from it, so Builder may rewrite it once it understands the goal. + # False is create-from-blank: the user named the app, Builder inherits it. + derive_app_name: bool = False model_config_data: SessionModel | None = Field(default=None, alias="model_config") @@ -252,6 +257,7 @@ class DifyBuilderSessionViewResponse(ResponseModel): entry_mode: EntryMode = EntryMode.FIX phase: Phase = Phase.UNDERSTAND actions: list[Action] = Field(default_factory=list) + decision: Decision | None = None active_interaction: DifyBuilderActiveInteractionResponse | None = None checkpoint: CheckpointRef | None = None recovery: RecoveryRef | None = None diff --git a/api/core/dify_builder/contract.py b/api/core/dify_builder/contract.py index a932a349fab..d3c9b9953fd 100644 --- a/api/core/dify_builder/contract.py +++ b/api/core/dify_builder/contract.py @@ -168,6 +168,69 @@ class Action: canvas_event: str | None = None +# The only two buttons an interactive card shows; every real choice is an option +# inside it. One interaction shape at every gate, not one per state. +CONFIRM_ACTION_ID = "confirm" +CANCEL_ACTION_ID = "cancel" + + +@dataclass +class OptionInput: + """Free text an option reveals once selected ("Something else", "Reject with a reason"). + + Bounds are declared for the client to enforce; see ``Decision`` for why the + server does not reject on them yet. + """ + + placeholder: str = "" + min_length: int = 0 + max_length: int = 100 + required: bool = True + + +@dataclass +class CardOption: + """One answer a gate offers, inside the card instead of as a button. + + ``id`` is the former standalone action id, unchanged, so an option resolves + to exactly the handler kind its button used to. + """ + + id: str + label: str + # The second line under an option. Empty for now: the per-state action table + # carries labels only, and writing the copy here is a product decision. + description: str = "" + # Shown as a DEFAULT badge. Advisory only -- these gates have side effects, + # so declining to choose must never apply anything. + is_default: bool = False + tone: str = "neutral" # neutral | destructive + input: OptionInput | None = None + next_state: str | None = None + canvas_event: str | None = None + + +@dataclass +class Decision: + """What the current gate asks: card options plus the fixed button pair. + + Replaces the per-state action bar. The client renders ``options`` in the + active card, shows only ``confirm`` and ``cancel``, and on confirm posts + ``confirm`` with ``{"option_id": ..., "free_text": ...}``. ``cancel`` is + presentational -- it dismisses without posting and the gate stays open. + + While ``SessionView.actions`` is still populated, the server accepts the old + per-action ids too and does not enforce ``OptionInput`` bounds, so a client + posting a bare action id keeps working. Enforcement moves server-side when + ``actions`` goes. + """ + + options: list[CardOption] = field(default_factory=list) + confirm: Action | None = None + cancel: Action | None = None + default_option_id: str = "" + + @dataclass class CheckpointRef: """Active restore point on the SessionView (spec §8).""" @@ -320,7 +383,10 @@ class SessionView: conversation_last_seq: int entry_mode: EntryMode = EntryMode.FIX phase: Phase = Phase.UNDERSTAND + # DEPRECATED until the web client reads ``decision``: the same choice, in + # the standalone-button shape. actions: list[Action] = field(default_factory=list) + decision: Decision | None = None active_interaction: ActiveInteraction | None = None checkpoint: CheckpointRef | None = None recovery: RecoveryRef | None = None diff --git a/api/core/dify_builder/handlers_build.py b/api/core/dify_builder/handlers_build.py index e827804edf0..66cab56c7ec 100644 --- a/api/core/dify_builder/handlers_build.py +++ b/api/core/dify_builder/handlers_build.py @@ -98,6 +98,30 @@ def _emit_completion(fc: DifyBuilderContext) -> list[ConversationItem]: return append_card(fc, SummaryCard(variant="completion", title="Build complete", rows=rows)) +def _refine_app_name(env: Env, fc: DifyBuilderContext) -> None: + """Replace the prompt-derived app name with a model-written one, once (spec N1). + + Creation named the app by cutting the prompt's opening clause -- mechanical, + and English-shaped where it has to judge. The goal has just been understood + here and a model is in hand, so the name can be rewritten properly, in + whatever language the user wrote. Plan approval then freezes it (spec N3). + + The flag is cleared whether or not the proposal was usable, so a session + never renames twice and never retries a model that gave nothing. It is + committed with the transition while the rename commits on its own, so an + advance that fails after this point proposes again next attempt and + overwrites with an equivalent title. + """ + if not fc.app_name_auto: + return + fc.app_name_auto = False + if env.rename_app is None: + return + proposed = env.agent.propose_app_name(fc.goal_text, fc.requirements) + if proposed and proposed.strip(): + env.rename_app(proposed) + + def handle_capability_check(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult: """(waiting) Entry state. On ``send_goal`` reset the canvas, analyze the goal into requirements, and transition to build.goal_analysis emitting its @@ -125,6 +149,7 @@ def handle_capability_check(env: Env, turn: Turn, s: Session, fc: DifyBuilderCon analysis = env.agent.analyze_goal(fc.goal_text) fc.form_fields = list(analysis.get("fields") or []) fc.requirements = dict(analysis.get("values") or {}) + _refine_app_name(env, fc) progress.activate("build-prepare-requirements") form_items = append_card( diff --git a/api/core/dify_builder/models.py b/api/core/dify_builder/models.py index 58e050a57cc..a9836a16249 100644 --- a/api/core/dify_builder/models.py +++ b/api/core/dify_builder/models.py @@ -266,6 +266,10 @@ class DifyBuilderContext: resource_selection: dict[str, Any] = field(default_factory=dict) built_node_ids: list[str] = field(default_factory=list) form_fields: list[dict] = field(default_factory=list) + # True while the app still carries the name cut from its creating prompt, so + # Builder may refine it once (spec N1); cleared the moment it does. Plan + # approval freezes the name for good (spec N3, 批准即定名). + app_name_auto: bool = False # -- Edit fields (Slice 3, additive) -- edit_rules: dict[str, Any] = field(default_factory=dict) diff --git a/api/core/dify_builder/naming.py b/api/core/dify_builder/naming.py new file mode 100644 index 00000000000..345afaf7b75 --- /dev/null +++ b/api/core/dify_builder/naming.py @@ -0,0 +1,150 @@ +"""Name an app after the goal prompt it was created from (App Builder spec, N1). + +Pure stdlib, so the console create path and the Builder engine can both use it. + +Extraction runs inside ``POST /apps``, so it is a clause cut rather than an +understanding of the sentence: instant and infallible. Anything needing +judgement is left to the Builder's goal-analysis pass, which has a model and the +detected reply language -- dropping an imperative lead-in is seeded for English +and Chinese only here, and a contentless prompt still yields a literal name +rather than the fallback. N3 freezes whatever name approval lands on. +""" + +import re +from datetime import datetime + +# Long enough to stay meaningful, short enough for a sidebar card. The spec's +# example cut ("Refund approval for ecommerce orders" -> "Refund approval for +# ecommerce") is this limit applied at the preceding word boundary. +MAX_NAME_LENGTH = 30 + +# Where the first clause ends; the rest is elaboration. ASCII terminators count +# only at a word break so "v1.5" and "gpt-4o-mini" survive, while CJK ones are +# unambiguous alone because CJK text puts no space after them. +_CLAUSE_BOUNDARY = re.compile(r"[—–;;\n\r]|[。!?…]|[.!?]+(?=\s|$)") + +# Imperative lead-ins, dropped so the name reads as a thing rather than an +# instruction. An unlisted language keeps its lead-in until the Builder renames. +_LEAD_INS = ( + r"i\s+(?:want|need|would\s+like)\s+(?:to\s+)?", + r"(?:can|could|would)\s+you\s+(?:please\s+)?", + r"please\s+", + r"help\s+me\s+(?:to\s+)?", + r"let'?s\s+", + r"(?:set\s+up|build|create|make|design|generate|write|add|implement)\s+(?:me\s+)?", + r"(?:a|an|the)\s+", + r"(?:请|帮我|帮忙|我想要|我想|我需要|我要)\s*", + r"(?:创建|新建|搭建|生成|设计|编写|做)(?:一个|一份|一套|个)?\s*", + r"(?:一个|一份|一套)\s*", # the Chinese article, once a pronoun above is gone +) +_LEAD_IN_RE = re.compile(r"^(?:" + "|".join(_LEAD_INS) + r")", re.IGNORECASE) + +# Lead-ins nest ("Please help me build an expense report app"); bound the peeling. +_MAX_LEAD_IN_PEELS = 6 + +# Any Unicode letter or ideograph. Without one there is no name to be had. +_HAS_LETTER = re.compile(r"[^\W\d_]") + +# "New app" per console locale (api/constants/languages.py). The fallback fires +# when the prompt had no words, so it speaks the console's language. +_FALLBACK_STEM: dict[str, str] = { + "en-US": "New app", + "zh-Hans": "新应用", + "zh-Hant": "新應用", + "pt-BR": "Novo app", + "es-ES": "Nueva aplicación", + "fr-FR": "Nouvelle application", + "de-DE": "Neue App", + "ja-JP": "新しいアプリ", + "ko-KR": "새 앱", + "lo-LA": "ແອັບໃໝ່", + "ru-RU": "Новое приложение", + "it-IT": "Nuova app", + "uk-UA": "Новий застосунок", + "vi-VN": "Ứng dụng mới", + "ro-RO": "Aplicație nouă", + "pl-PL": "Nowa aplikacja", + "hi-IN": "नया ऐप", + "tr-TR": "Yeni uygulama", + "fa-IR": "برنامه جدید", + "sl-SI": "Nova aplikacija", + "th-TH": "แอปใหม่", + "id-ID": "Aplikasi baru", + "ar-TN": "تطبيق جديد", + "nl-NL": "Nieuwe app", +} +_DEFAULT_LANGUAGE = "en-US" + +# Only the stamp's shape varies. Other locales take the English month +# abbreviation: unambiguous everywhere, and cheaper to keep right than 24 month +# tables for a name meant to be replaced. +_CJK_STAMP = frozenset({"zh-Hans", "zh-Hant", "ja-JP"}) +_MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + + +def fallback_app_name(now: datetime, language: str = _DEFAULT_LANGUAGE) -> str: + """The name for a prompt with nothing nameable in it: ``New app · Sep 7, 14:02``. + + ``now`` is used as given -- pass it in the account's own timezone, since the + stamp exists to be recognised by the person who just typed. + """ + stem = _FALLBACK_STEM.get(language, _FALLBACK_STEM[_DEFAULT_LANGUAGE]) + if language in _CJK_STAMP: + stamp = f"{now.month}月{now.day}日 {now:%H:%M}" + elif language == "ko-KR": + stamp = f"{now.month}월 {now.day}일 {now:%H:%M}" + else: + stamp = f"{_MONTH_ABBR[now.month - 1]} {now.day}, {now:%H:%M}" + return f"{stem} · {stamp}" + + +def _first_clause(prompt: str) -> str: + return _CLAUSE_BOUNDARY.split(prompt.strip(), maxsplit=1)[0] + + +def _strip_lead_ins(text: str) -> str: + for _ in range(_MAX_LEAD_IN_PEELS): + peeled = _LEAD_IN_RE.sub("", text, count=1) + # Stop before peeling the name away entirely ("Build me an app" -> "app"). + if peeled == text or not _HAS_LETTER.search(peeled): + return text + text = peeled + return text + + +def _truncate(text: str) -> str: + if len(text) <= MAX_NAME_LENGTH: + return text + head = text[:MAX_NAME_LENGTH] + # Back off to the preceding word boundary when the cut landed inside a word. + # Scripts without spaces (CJK, Thai) have none, and cutting at the character + # is already right there. + if not text[MAX_NAME_LENGTH].isspace() and (boundary := head.rfind(" ")) > 0: + head = head[:boundary] + return head.rstrip() + + +def derive_app_name(prompt: str, *, now: datetime, language: str = _DEFAULT_LANGUAGE) -> str: + """Name an app after the prompt that created it. Never raises.""" + clause = re.sub(r"\s+", " ", _first_clause(prompt or "")).strip() + candidate = _strip_lead_ins(clause) + peeled = candidate != clause + candidate = _truncate(candidate).strip(" ,.:;·-—–、,。") + if not _HAS_LETTER.search(candidate): + return fallback_app_name(now, language) + if peeled and candidate[:1].islower(): + # Peeling "Set up an " leaves a fragment mid-sentence-case. Scripts + # without case are unaffected. + candidate = candidate[0].upper() + candidate[1:] + return candidate + + +def normalize_proposed_name(name: str) -> str: + """Make a model-proposed name safe to store, or "" to keep the derived one. + + The model is asked for a short title but not trusted to have produced one. + A refinement that cannot improve on the derived name must not replace it. + """ + candidate = re.sub(r"\s+", " ", name or "").strip().strip("\"'“”‘’「」『』") + candidate = _truncate(candidate).strip() + return candidate if _HAS_LETTER.search(candidate) else "" diff --git a/api/core/dify_builder/placeholder_agent.py b/api/core/dify_builder/placeholder_agent.py index 04efd2c3b07..2523b0296f2 100644 --- a/api/core/dify_builder/placeholder_agent.py +++ b/api/core/dify_builder/placeholder_agent.py @@ -103,6 +103,11 @@ class PlaceholderAgent: }, } + def propose_app_name(self, goal_text: str, requirements: dict[str, Any]) -> str: + # No model, so nothing better than what the create path already cut out + # of the prompt. Returning "" leaves that derived name in place. + return "" + def propose_plan_v1(self, requirements: dict[str, Any]) -> list[str]: return [ "Ingest source documents", diff --git a/api/core/dify_builder/ports.py b/api/core/dify_builder/ports.py index bac8f28d4f7..55cd0815a19 100644 --- a/api/core/dify_builder/ports.py +++ b/api/core/dify_builder/ports.py @@ -77,6 +77,10 @@ class DifyBuilderAgent(Protocol): def analyze_goal(self, goal_text: str) -> dict[str, Any]: ... + # Short human title for a prompt-created app. Returns the raw proposal; the + # service layer normalizes and stores it. + def propose_app_name(self, goal_text: str, requirements: dict[str, Any]) -> str: ... + def propose_plan_v1(self, requirements: dict[str, Any]) -> list[str]: ... def discover_resources(self, plan_items: list[str]) -> list[ResourceOption]: ... diff --git a/api/core/dify_builder/runner.py b/api/core/dify_builder/runner.py index 33029ced00c..f50e2168b26 100644 --- a/api/core/dify_builder/runner.py +++ b/api/core/dify_builder/runner.py @@ -115,6 +115,11 @@ class Env: # MUST NOT raise: the engine does not guard this call, so the service-layer # implementation is responsible for swallowing its own errors and returning a safe fallback. localize_items: Callable[[list["ConversationItem"], str], list["ConversationItem"]] | None = None + # Renames the session's app to a model-proposed title (spec N1). None -> no-op. + # MUST NOT raise: the engine does not guard this call, so the service-layer + # implementation swallows its own errors -- a failed rename leaves the + # derived name in place and never blocks the build. + rename_app: Callable[[str], None] | None = None # Correlation metadata for the handler transition currently running. # The runner resets it before every independently committed step. session_id: str = "" diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 1eeb5031c5d..ca75952f3eb 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -16452,6 +16452,19 @@ snapshot must reconstruct the same canvas. | session_id | string | | Yes | | stage_id | string | | Yes | +#### CardOption + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| canvas_event | string | | No | +| description | string | | No | +| id | string | | Yes | +| input | [OptionInput](#optioninput) | | No | +| is_default | boolean | | No | +| label | string | | Yes | +| next_state | string | | No | +| tone | string,
**Default:** neutral | | No | + #### ChallengeCard | Name | Type | Description | Required | @@ -17080,7 +17093,8 @@ Enum class for configurate method of provider model. | icon_background | string | Icon background color | No | | icon_type | [IconType](#icontype) | Icon type | No | | mode | string,
**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | App mode
*Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes | -| name | string | App name | Yes | +| name | string | App name; derived from prompt when omitted | No | +| prompt | string | Goal prompt the app was created from; used to derive the name | No | #### CreateSnippetPayload @@ -17813,6 +17827,15 @@ Enum class for datasource provider | start_node_id | string | | Yes | | start_node_title | string | | Yes | +#### Decision + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| cancel | [Action](#action) | | No | +| confirm | [Action](#action) | | No | +| default_option_id | string | | No | +| options | [ [CardOption](#cardoption) ] | | No | + #### DecisionItem | Name | Type | Description | Required | @@ -18044,6 +18067,7 @@ Enum representing the deployment edition of the platform. | canvas_read_only | boolean | | Yes | | checkpoint | [CheckpointRef](#checkpointref) | | No | | conversation_last_seq | integer | | Yes | +| decision | [Decision](#decision) | | No | | entry_mode | [EntryMode](#entrymode) | | No | | interrupted | boolean | | Yes | | kind | string,
**Default:** command_started | | No | @@ -18105,6 +18129,7 @@ Enum representing the deployment edition of the platform. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | app_id | string | | Yes | +| derive_app_name | boolean | | No | | goal_text | string | | Yes | | model_config | [SessionModel](#sessionmodel) | | No | | scenario | string | | Yes | @@ -18273,6 +18298,7 @@ Create a Build, Edit, failed-run Fix, or checklist Fix session. | canvas_read_only | boolean | | Yes | | checkpoint | [CheckpointRef](#checkpointref) | | No | | conversation_last_seq | integer | | Yes | +| decision | [Decision](#decision) | | No | | entry_mode | [EntryMode](#entrymode) | | No | | interrupted | boolean | | Yes | | model | [SessionModel](#sessionmodel) | | No | @@ -18294,6 +18320,7 @@ Create a Build, Edit, failed-run Fix, or checklist Fix session. | canvas_read_only | boolean | | Yes | | checkpoint | [CheckpointRef](#checkpointref) | | No | | conversation_last_seq | integer | | Yes | +| decision | [Decision](#decision) | | No | | entry_mode | [EntryMode](#entrymode) | | No | | interrupted | boolean | | Yes | | kind | string,
**Default:** state | | No | @@ -18930,6 +18957,7 @@ declaration of an endpoint group | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | body | string | | Yes | +| diagnostics | [ object ] | | No | | node_id | string | | No | | title | string | | Yes | | tone | string,
**Default:** danger | | No | @@ -20853,6 +20881,15 @@ OAuth schema | label | [I18nObject](#i18nobject) | The label of the option | Yes | | value | string | The value of the option | Yes | +#### OptionInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| max_length | integer,
**Default:** 100 | | No | +| min_length | integer | | No | +| placeholder | string | | No | +| required | boolean,
**Default:** true | | No | + #### OutputErrorStrategy Per-output failure handling strategy. diff --git a/api/services/app_service.py b/api/services/app_service.py index 859f87c09a7..8e3f79a2234 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -2,9 +2,10 @@ import json import logging from collections.abc import Callable, Sequence from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from typing import Any, Literal, NotRequired, TypedDict, cast, override +import pytz import sqlalchemy as sa from pydantic import BaseModel, Field from sqlalchemy import ColumnElement, delete, select @@ -15,6 +16,7 @@ from configs import dify_config from constants.model_template import default_app_templates from core.agent.entities import AgentToolEntity from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot +from core.dify_builder import naming from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError from core.model_manager import ModelManager from core.tools.tool_manager import ToolManager @@ -149,8 +151,17 @@ class RecentAppListItem: maintainer: str | None +# Only a prompt's opening clause can become a name, so nothing past this point +# can affect the result. +_NAME_PROMPT_SCAN_LIMIT = 2000 + + class CreateAppParams(BaseModel): - name: str = Field(min_length=1) + # Optional since App Builder: an app created from a goal prompt is named + # after it (``core.dify_builder.naming``). A blank pair still names the app + # rather than failing. + name: str | None = Field(default=None, min_length=1) + prompt: str | None = None description: str | None = None mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] agent_role: str = Field(default="", max_length=255) @@ -575,6 +586,27 @@ class AppService: session.delete(existing_star) + @staticmethod + def _resolve_app_name(params: CreateAppParams, account: Account) -> str: + """The name to store (App Builder spec, board N). + + A name the user gave wins outright -- that is create-from-blank, where + Builder only inherits. Otherwise it is cut out of the goal prompt, with + the account's timezone and console language shaping the + ``New app · Sep 7, 14:02`` fallback. + """ + if params.name: + return params.name + try: + now = datetime.now(pytz.timezone(account.timezone or "UTC")) + except Exception: + now = datetime.now(UTC) + return naming.derive_app_name( + (params.prompt or "")[:_NAME_PROMPT_SCAN_LIMIT], + now=now, + language=account.interface_language or "en-US", + ) + def create_app( self, tenant_id: str, @@ -659,7 +691,7 @@ class AppService: default_model_config["model"] = json.dumps(default_model_dict) app = App(**app_template["app"]) - app.name = params.name + app.name = self._resolve_app_name(params, account) app.description = params.description or "" app.mode = app_mode app.icon_type = IconType(params.icon_type) if params.icon_type else IconType.EMOJI @@ -979,13 +1011,22 @@ class AppService: :return: App instance """ assert current_user is not None + return self.rename_app(app, name, account_id=current_user.id, session=session) + + def rename_app(self, app: App, name: str, *, account_id: str, session: Session) -> App: + """Rename an app on behalf of ``account_id``. + + :meth:`update_app_name` with the actor passed in rather than read off the + request, so callers outside a request context -- the Dify Builder advance + task -- can rename without reaching for ``current_user``. + """ app.name = name - app.updated_by = current_user.id + app.updated_by = account_id app.updated_at = naive_utc_now() self._sync_backing_agent_identity( app, name=app.name, - account_id=current_user.id, + account_id=account_id, updated_at=app.updated_at, session=session, ) diff --git a/api/services/dify_builder/agent/build.py b/api/services/dify_builder/agent/build.py index dea73a5d5a9..de8d0246547 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 json import logging from collections.abc import Callable, Sequence from datetime import UTC, datetime @@ -21,6 +22,11 @@ from services.workflow_generator_service import WorkflowGeneratorService logger = logging.getLogger(__name__) +# A reply longer than this is prose, not a title. Well above +# ``naming.MAX_NAME_LENGTH`` so a good-but-long title is trimmed downstream +# rather than rejected here. +_MAX_PROPOSED_NAME_CHARS = 80 + def analyze_goal( model, @@ -47,6 +53,43 @@ def analyze_goal( return {"fields": form_schema.reconcile_form_fields(fields, values), "values": values} +def propose_app_name( + model, + goal_text: str, + requirements: dict[str, Any], + on_reasoning: Callable[[str], None] | None = None, +) -> str: + """Name the app the way a person would title it (spec N1). + + Returns "" whenever it cannot beat the name already cut from the prompt -- + no model, a failed call, or a sentence instead of a title. The caller then + keeps the derived name, so this never has to be right, only safe. + """ + if model is None: + return "" + system = ( + "You name Dify apps. Given the goal the user typed, reply with a short title for " + "the app -- a noun phrase naming what it does, at most 5 words, no trailing " + "punctuation, no quotes, not a sentence and not an instruction. " + "Write it in the same language the user wrote the goal in. " + "Reply with ONLY the title." + ) + user = f"GOAL:\n{goal_text}" + if requirements: + user += f"\n\nREQUIREMENTS:\n{json.dumps(requirements, ensure_ascii=False)[:1000]}" + try: + raw = llm.invoke_text(model, system=system, user=user, on_reasoning=on_reasoning).strip() + except Exception: + logger.warning("dify_builder: app-name proposal failed; keeping the derived name", exc_info=True) + return "" + # A sentence ("Sure! Here is a good name for...") is worse than the + # mechanical cut, so accept only a title-shaped reply. + first_line = raw.splitlines()[0].strip() if raw else "" + if not first_line or len(first_line) > _MAX_PROPOSED_NAME_CHARS: + return "" + return first_line + + def _degraded_form(goal_text: str) -> dict[str, Any]: return { "fields": [{"key": "goal", "label": "Goal", "type": "textarea", "options": []}], diff --git a/api/services/dify_builder/agent/llm_agent.py b/api/services/dify_builder/agent/llm_agent.py index 90abe620167..628b9655547 100644 --- a/api/services/dify_builder/agent/llm_agent.py +++ b/api/services/dify_builder/agent/llm_agent.py @@ -102,6 +102,14 @@ class LlmBuilderAgent: def analyze_goal(self, goal_text): return build.analyze_goal(self.model_or_none(), goal_text, self._reasoning_for("analyze-goal")) + def propose_app_name(self, goal_text, requirements): + return build.propose_app_name( + self.model_or_none(), + goal_text, + requirements, + self._reasoning_for("propose-app-name"), + ) + def propose_plan_v1(self, requirements): return build.propose_plan_v1( self.model_or_none(), diff --git a/api/services/dify_builder/app_naming.py b/api/services/dify_builder/app_naming.py new file mode 100644 index 00000000000..f3e7e7481d5 --- /dev/null +++ b/api/services/dify_builder/app_naming.py @@ -0,0 +1,46 @@ +"""Store the Builder's proposed app name (spec N1). + +The seam between the model's proposal and the app row. Strictly best-effort: a +build that is going fine must not fail because a cosmetic rename did, so every +failure is logged and swallowed and the app keeps its derived name. +""" + +import logging + +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from core.dify_builder import naming +from extensions.ext_database import db +from models.model import App +from services.app_service import AppService + +logger = logging.getLogger(__name__) + + +def rename_app_from_proposal(*, app_id: str, tenant_id: str, account_id: str, proposed: str) -> None: + """Store ``proposed`` as the app's name, if it is usable. Never raises. + + Opens its own session: this runs in the Celery advance task, outside any + Flask request. + """ + name = naming.normalize_proposed_name(proposed) + if not name: + return + try: + factory = sessionmaker(bind=db.engine, expire_on_commit=False) + with factory() as session: + app = _load_app(session, app_id=app_id, tenant_id=tenant_id) + if app is None or app.name == name: + return + AppService().rename_app(app, name, account_id=account_id, session=session) + except Exception: + # Nothing downstream reads the name, so there is nothing to recover -- + # only a clumsier title to look at. + logger.warning("dify_builder: renaming app %s failed; keeping the derived name", app_id, exc_info=True) + + +def _load_app(session: Session, *, app_id: str, tenant_id: str) -> App | None: + # Tenant-scoped: the actor's tenant is the only authority this path carries, + # so a cross-tenant id must find nothing rather than rename. + return session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant_id)) diff --git a/api/services/dify_builder/serde.py b/api/services/dify_builder/serde.py index cf938028ad0..70f83e884f0 100644 --- a/api/services/dify_builder/serde.py +++ b/api/services/dify_builder/serde.py @@ -68,6 +68,7 @@ def context_from_dict(d: dict[str, Any]) -> DifyBuilderContext: resource_selection=dict(d.get("resource_selection") or {}), built_node_ids=list(d.get("built_node_ids") or []), form_fields=list(d.get("form_fields") or []), + app_name_auto=bool(d.get("app_name_auto", False)), edit_rules=dict(d.get("edit_rules") or {}), edit_target_node_ids=list(d.get("edit_target_node_ids") or []), paused=bool(d.get("paused", False)), diff --git a/api/services/dify_builder/service.py b/api/services/dify_builder/service.py index da223ec77b8..6fd5104e1c3 100644 --- a/api/services/dify_builder/service.py +++ b/api/services/dify_builder/service.py @@ -15,14 +15,18 @@ from typing import Protocol from uuid import uuid4 from core.dify_builder import recovery -from core.dify_builder.contract import Action as UiAction from core.dify_builder.contract import ( + CANCEL_ACTION_ID, + CONFIRM_ACTION_ID, ActionKind, ActiveInteraction, AppRevision, + CardOption, CheckpointRef, ConversationPage, + Decision, NoticeItem, + OptionInput, Phase, RunContextCard, RunStatus, @@ -30,6 +34,7 @@ from core.dify_builder.contract import ( SessionView, UserItem, ) +from core.dify_builder.contract import Action as UiAction from core.dify_builder.errors import BadRequestError, BusyError, ConflictError, NotFoundError from core.dify_builder.models import ( Action, @@ -45,7 +50,14 @@ from core.dify_builder.state import PcState, canvas_read_only, is_terminal, is_w from services.dify_builder.agent.model_resolver import validate_model_config from services.feature_service import FeatureService -__all__ = ["AppAccess", "DifyBuilderService", "SessionLock", "SessionView", "resolve_action_kind"] +__all__ = [ + "AppAccess", + "DifyBuilderService", + "SessionLock", + "SessionView", + "resolve_action_kind", + "resolve_submitted_action", +] class AppAccess(StrEnum): @@ -401,6 +413,51 @@ _ACTION_ID_TO_KIND: dict[str, str] = { } +# Options that need a sentence from the user to be an answer at all. Builder +# replans from the rejection text, so a bare "no" is not something it can act on. +_OPTION_INPUTS: dict[str, OptionInput] = { + "reject_repair": OptionInput( + placeholder="Explain the rejection -- Builder replans from it", + min_length=10, + max_length=200, + required=True, + ), +} + + +def _decision_for(actions: list[UiAction]) -> Decision | None: + """Project a gate's actions as card options plus the fixed buttons. + + Same choice, different shape: a button row that changed per state becomes + options with one confirm and one cancel under them. Option ids are the old + action ids untouched, which keeps ``resolve_action_kind`` and the per-state + legality check working unchanged. + """ + renderable = [a for a in actions if a.kind != ActionKind.AUTOMATIC] + if not renderable: + return None + # Only the first primary is badged: two recommendations is no recommendation. + default_index = next((i for i, a in enumerate(renderable) if a.kind == ActionKind.PRIMARY), -1) + options = [ + CardOption( + id=a.id, + label=a.label, + is_default=(i == default_index), + tone="destructive" if a.kind == ActionKind.DESTRUCTIVE else "neutral", + input=_OPTION_INPUTS.get(a.id), + next_state=a.next_state, + canvas_event=a.canvas_event, + ) + for i, a in enumerate(renderable) + ] + return Decision( + options=options, + confirm=UiAction(id=CONFIRM_ACTION_ID, label="Confirm", kind=ActionKind.PRIMARY), + cancel=UiAction(id=CANCEL_ACTION_ID, label="Cancel", kind=ActionKind.SECONDARY), + default_option_id=options[default_index].id if default_index >= 0 else "", + ) + + _ACTIVE_INTERACTION_CARD_FOR_ACTION: dict[str, tuple[str, str | None]] = { "submit_requirements": ("form", "build_requirements"), "submit_edit_rules": ("form", "edit_rules"), @@ -520,6 +577,20 @@ def resolve_action_kind(raw: str) -> str: return _ACTION_ID_TO_KIND.get(raw, raw) +def resolve_submitted_action(action_id: str, payload: dict | None) -> str: + """Map what the client posted to the engine handler kind. + + The card-options client posts ``confirm`` and names its choice in + ``payload["option_id"]``; an action-bar client posts that id directly. Both + reach the same kind because an option id IS the action id it replaced, which + is what lets the two contracts run side by side. A ``confirm`` naming no + option resolves to "", which the per-state legality check then rejects. + """ + if action_id == CONFIRM_ACTION_ID: + action_id = str(payload.get("option_id") or "") if isinstance(payload, dict) else "" + return resolve_action_kind(action_id) + + _WAITING_INPUT_STATES = frozenset( { PcState.FIX_AWAIT_TESTDATA, @@ -779,7 +850,12 @@ class DifyBuilderService: return stream_advance_frames(view_dict, subscription, expect_advance=True) def _prepare_build_session( - self, app_id: str, actor: Actor, goal_text: str, model_config: dict | None = None + self, + app_id: str, + actor: Actor, + goal_text: str, + model_config: dict | None = None, + derive_app_name: bool = False, ) -> tuple[str, Action]: """Shared setup for ``create_build_session``/``create_build_session_stream``: everything through persisting the session up to -- but not including -- @@ -795,6 +871,7 @@ class DifyBuilderService: skill_learning_policy=policy, model_config=model_config, last_snapshot_hash=app_revision, + app_name_auto=derive_app_name, ) s = Session( app_id=app_id, @@ -813,25 +890,35 @@ class DifyBuilderService: ) def create_build_session( - self, app_id: str, actor: Actor, goal_text: str, model_config: dict | None = None + self, + app_id: str, + actor: Actor, + goal_text: str, + model_config: dict | None = None, + derive_app_name: bool = False, ) -> SessionView: """Start a Build session at build.capability_check and dispatch the initial ``send_goal`` (parallels ``create_fix_session``). The goal is seeded as the user's opening bubble; the first advance's ``handle_capability_check`` analyzes it into requirements.""" - session_id, action = self._prepare_build_session(app_id, actor, goal_text, model_config) + session_id, action = self._prepare_build_session(app_id, actor, goal_text, model_config, derive_app_name) if action is not None: self.dispatch(session_id, action, actor) return self.get_session_view(session_id, actor) def create_build_session_stream( - self, app_id: str, actor: Actor, goal_text: str, model_config: dict | None = None + self, + app_id: str, + actor: Actor, + goal_text: str, + model_config: dict | None = None, + derive_app_name: bool = False, ) -> Iterator[str]: """Streaming counterpart of ``create_build_session``: subscribes BEFORE dispatching the initial ``send_goal`` advance.""" from services.dify_builder.wiring import stream_advance_frames - session_id, action = self._prepare_build_session(app_id, actor, goal_text, model_config) + session_id, action = self._prepare_build_session(app_id, actor, goal_text, model_config, derive_app_name) subscription = self._subscribe_fn(session_id) # BEFORE dispatch try: self.dispatch(session_id, action, actor) @@ -1004,6 +1091,7 @@ class DifyBuilderService: entry_mode=s.entry_mode, phase=_phase_for(st), actions=actions, + decision=_decision_for(actions), active_interaction=self._active_interaction_for(s.id, s.version, actions), checkpoint=checkpoint, recovery=recovery_ref, diff --git a/api/tasks/dify_builder_advance_task.py b/api/tasks/dify_builder_advance_task.py index 95770b305cb..9aa4eaa0756 100644 --- a/api/tasks/dify_builder_advance_task.py +++ b/api/tasks/dify_builder_advance_task.py @@ -46,7 +46,7 @@ from core.dify_builder.runner import CommittedTransition, Env, Runner from core.dify_builder.state import PcState, is_terminal from extensions.ext_database import db from libs.datetime_utils import naive_utc_now -from services.dify_builder import progress_bus, session_lock +from services.dify_builder import app_naming, progress_bus, session_lock from services.dify_builder.agent.localize import Localizer from services.dify_builder.agent_factory import build_dify_builder_agent from services.dify_builder.dify_port import WorkflowServiceDifyPort @@ -217,6 +217,17 @@ def advance_session(session_id: str, action_dict: dict, actor_dict: dict, token: # `model_or_none` is part of the DifyBuilderAgent protocol; the Localizer # uses it as its model provider (None -> reply-language localization is a no-op). localizer = Localizer(agent.model_or_none) + + def rename_app(proposed: str) -> None: + assert actor is not None + assert loaded_session is not None + app_naming.rename_app_from_proposal( + app_id=loaded_session.app_id, + tenant_id=actor.tenant_id, + account_id=actor.account_id, + proposed=proposed, + ) + env = Env( dify=dify, agent=agent, @@ -230,6 +241,7 @@ def advance_session(session_id: str, action_dict: dict, actor_dict: dict, token: emit_reasoning=emit_reasoning, detect_language=localizer.detect_language, localize_items=localizer.localize_items, + rename_app=rename_app, ) runner = Runner(env, fix_registry() | build_registry() | edit_registry()) env.begin_operation(loaded_session) diff --git a/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py b/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py index dfb2a4c1398..04d300a8fdb 100644 --- a/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py +++ b/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py @@ -22,3 +22,24 @@ class TestCreateAppPayloadMode: def test_rejects_unknown_mode(self): with pytest.raises(ValidationError): CreateAppPayload.model_validate({"name": "X", "mode": "not-a-mode"}) + + +class TestCreateAppPayloadNaming: + """App Builder creates from a prompt, so the client may send no name.""" + + def test_accepts_a_prompt_without_a_name(self): + payload = CreateAppPayload.model_validate( + {"mode": "workflow", "prompt": "Refund approval for ecommerce orders"} + ) + assert payload.name is None + assert payload.prompt == "Refund approval for ecommerce orders" + + def test_accepts_neither_name_nor_prompt(self): + payload = CreateAppPayload.model_validate({"mode": "workflow"}) + assert payload.name is None + assert payload.prompt is None + + def test_still_rejects_an_empty_name(self): + # Omitting the name asks the server to derive one; sending "" is a bug. + with pytest.raises(ValidationError): + CreateAppPayload.model_validate({"name": "", "mode": "workflow"}) diff --git a/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py b/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py index 872534e3dda..c9739ffaf0c 100644 --- a/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py +++ b/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py @@ -127,7 +127,7 @@ def test_conversation_get_rejects_competing_cursors(monkeypatch): ( {"scenario": "build", "app_id": "a1", "goal_text": "Build it"}, "create_build_session_stream", - {"app_id": "a1", "goal_text": "Build it", "model_config": None}, + {"app_id": "a1", "goal_text": "Build it", "model_config": None, "derive_app_name": False}, ), ( {"scenario": "edit", "app_id": "a1", "goal_text": "Tighten risk"}, @@ -543,3 +543,64 @@ def test_decorator_stack_gate_blocks_when_feature_off(monkeypatch): def test_session_routes_require_legacy_edit_permission(method): gate = unwrap(method, stop=lambda wrapper: "edit_permission_required" in wrapper.__code__.co_qualname) assert "edit_permission_required" in gate.__code__.co_qualname + + +def test_create_build_defaults_to_not_renaming_the_app(monkeypatch): + service = MagicMock() + service.create_build_session_stream.return_value = _frames() + monkeypatch.setattr(mod, "build_service", lambda: service) + + mod._create({"scenario": "build", "app_id": "a1", "goal_text": "Build it"}, _actor()) + + assert service.create_build_session_stream.call_args.kwargs["derive_app_name"] is False + + +def test_create_build_forwards_the_rename_opt_in(monkeypatch): + service = MagicMock() + service.create_build_session_stream.return_value = _frames() + monkeypatch.setattr(mod, "build_service", lambda: service) + + mod._create( + {"scenario": "build", "app_id": "a1", "goal_text": "Build it", "derive_app_name": True}, + _actor(), + ) + + assert service.create_build_session_stream.call_args.kwargs["derive_app_name"] is True + + +def test_action_resolves_confirm_through_the_option_the_client_chose(monkeypatch): + service = MagicMock() + service.submit_action_stream.return_value = _frames() + monkeypatch.setattr(mod, "build_service", lambda: service) + + response = mod._action( + "s1", + { + "action_id": "confirm", + "payload": {"option_id": "run_validation", "free_text": ""}, + "base_version": 1, + "base_app_revision": "hash-1", + }, + _actor(), + ) + + _assert_event_stream(response) + + action = service.submit_action_stream.call_args.args[2] + assert action.kind == "run_verify" + # The chosen option and any free text travel on to the handler untouched. + assert action.payload == {"option_id": "run_validation", "free_text": ""} + + +def test_action_still_accepts_a_client_posting_the_bare_action_id(monkeypatch): + service = MagicMock() + service.submit_action_stream.return_value = _frames() + monkeypatch.setattr(mod, "build_service", lambda: service) + + mod._action( + "s1", + {"action_id": "run_validation", "payload": {}, "base_version": 1, "base_app_revision": "hash-1"}, + _actor(), + ) + + assert service.submit_action_stream.call_args.args[2].kind == "run_verify" diff --git a/api/tests/unit_tests/core/dify_builder/fakes.py b/api/tests/unit_tests/core/dify_builder/fakes.py index 2e564a3ce3a..ad5fc88b771 100644 --- a/api/tests/unit_tests/core/dify_builder/fakes.py +++ b/api/tests/unit_tests/core/dify_builder/fakes.py @@ -409,6 +409,9 @@ class StubAgent: def analyze_goal(self, _goal_text): return {} + def propose_app_name(self, _goal_text, _requirements): + return "" + def propose_plan_v1(self, _requirements): return [] diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py index 629eb5d7fd6..06634d4df4e 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py @@ -1332,3 +1332,68 @@ def test_recovery_restart_resets_interrupted_step_to_entry_state(): reloaded, fc = repo.get_session(s.id) assert reloaded.current_state == entry_state_for(EntryMode.BUILD) # back at the flow entry assert fc.staged_repair == [] # working fields reset + + +class _NamingAgent(PlaceholderAgent): + """Placeholder cognition plus a model that has an opinion about the name.""" + + def __init__(self, proposal: str = "Expense reimbursement approval") -> None: + self.proposal = proposal + self.calls: list[tuple[str, dict]] = [] + + def propose_app_name(self, goal_text, requirements): + self.calls.append((goal_text, dict(requirements))) + return self.proposal + + +def _run_capability_check(agent, *, app_name_auto: bool, rename_app=None): + from core.dify_builder.handlers_build import build_registry + + env, repo = _new_env(agent=agent) + env.rename_app = rename_app + s = _seed_build_session(repo, PcState.BUILD_CAPABILITY_CHECK, app_name_auto=app_name_auto) + runner = Runner(env, build_registry()) + runner.advance(s.id, Turn(action=Action(kind="send_goal", payload={"text": "x"}, base_version=1), actor=_actor())) + _session_after, fc = repo.get_session(s.id) + return fc + + +def test_goal_analysis_renames_an_app_still_carrying_its_derived_name(): + agent = _NamingAgent() + renamed: list[str] = [] + fc = _run_capability_check(agent, app_name_auto=True, rename_app=renamed.append) + + assert renamed == ["Expense reimbursement approval"] + # The goal and the just-analyzed requirements are both handed to the model. + assert agent.calls[0][0] == "x" + assert agent.calls[0][1] == fc.requirements + # Cleared, so a later advance through this state never renames again. + assert fc.app_name_auto is False + + +def test_an_app_named_by_its_user_is_never_renamed(): + agent = _NamingAgent() + renamed: list[str] = [] + fc = _run_capability_check(agent, app_name_auto=False, rename_app=renamed.append) + + assert renamed == [] + assert agent.calls == [] + assert fc.app_name_auto is False + + +def test_a_model_with_no_proposal_leaves_the_derived_name_alone(): + agent = _NamingAgent(proposal=" ") + renamed: list[str] = [] + fc = _run_capability_check(agent, app_name_auto=True, rename_app=renamed.append) + + assert renamed == [] + # Still consumed: one shot per session, whether or not it produced anything. + assert fc.app_name_auto is False + + +def test_no_rename_callback_wired_costs_no_model_call(): + agent = _NamingAgent() + fc = _run_capability_check(agent, app_name_auto=True, rename_app=None) + + assert agent.calls == [] + assert fc.app_name_auto is False diff --git a/api/tests/unit_tests/core/dify_builder/test_naming.py b/api/tests/unit_tests/core/dify_builder/test_naming.py new file mode 100644 index 00000000000..d29bbb0b047 --- /dev/null +++ b/api/tests/unit_tests/core/dify_builder/test_naming.py @@ -0,0 +1,113 @@ +"""App names derived from the creating prompt (App Builder spec, board N).""" + +from datetime import datetime + +import pytest + +from core.dify_builder import naming + +NOW = datetime(2026, 9, 7, 14, 2) + + +def test_derives_the_name_from_the_first_clause(): + # The spec's own example: the trailing noun is dropped at the word boundary + # that keeps the name inside MAX_NAME_LENGTH. + assert naming.derive_app_name("Refund approval for ecommerce orders", now=NOW) == "Refund approval for ecommerce" + + +def test_drops_an_imperative_lead_in_and_recases_the_fragment(): + assert ( + naming.derive_app_name( + "Set up an expense reimbursement process — check claims against our policy", + now=NOW, + ) + == "Expense reimbursement process" + ) + + +def test_peels_nested_lead_ins(): + assert naming.derive_app_name("Please help me build an expense report app", now=NOW) == "Expense report app" + + +def test_keeps_a_name_that_is_already_a_noun_phrase(): + assert naming.derive_app_name("Daily ticket summary", now=NOW) == "Daily ticket summary" + + +@pytest.mark.parametrize( + ("prompt", "expected"), + [ + ("帮我创建一个报销审批流程。检查金额并通知经理", "报销审批流程"), + ("请设计一份文章摘要生成器", "文章摘要生成器"), + # A quantifier left behind by the pronoun is peeled like an article. + ("我要一个报销审批流程", "报销审批流程"), + ("我想要一套客服机器人", "客服机器人"), + ], +) +def test_chinese_prompts_keep_their_own_language(prompt: str, expected: str): + # The name is cut out of the prompt, so it needs no translation. + assert naming.derive_app_name(prompt, now=NOW) == expected + + +def test_a_version_or_model_string_is_not_a_clause_boundary(): + assert naming.derive_app_name("Build a workflow using gpt-4o-mini. Classify tickets.", now=NOW) == ( + "Workflow using gpt-4o-mini" + ) + + +def test_a_cjk_terminator_ends_the_clause_without_a_trailing_space(): + assert naming.derive_app_name("文章摘要生成器。它读取网页", now=NOW) == "文章摘要生成器" + + +@pytest.mark.parametrize("prompt", ["", " ", "??? !!!", "123 456"]) +def test_a_prompt_with_nothing_nameable_falls_back(prompt: str): + assert naming.derive_app_name(prompt, now=NOW) == "New app · Sep 7, 14:02" + + +def test_peeling_never_empties_the_name(): + # "app" is all that is left; peeling the article too would leave nothing. + assert naming.derive_app_name("Build me an app", now=NOW) == "App" + + +def test_a_long_unspaced_name_is_cut_at_the_character(): + # CJK has no word boundary to back off to, so the cut lands on the limit. + prompt = "报" * 50 + assert naming.derive_app_name(prompt, now=NOW) == "报" * naming.MAX_NAME_LENGTH + + +def test_the_cut_never_severs_a_word_in_a_spaced_script(): + name = naming.derive_app_name("Invoices to spreadsheet for my daily expense in company", now=NOW) + assert len(name) <= naming.MAX_NAME_LENGTH + assert not name.endswith(" ") + assert name == "Invoices to spreadsheet for my" + + +@pytest.mark.parametrize( + ("language", "expected"), + [ + ("en-US", "New app · Sep 7, 14:02"), + ("zh-Hans", "新应用 · 9月7日 14:02"), + ("ja-JP", "新しいアプリ · 9月7日 14:02"), + ("ko-KR", "새 앱 · 9월 7일 14:02"), + ("fr-FR", "Nouvelle application · Sep 7, 14:02"), + # An unknown locale takes the English stem rather than raising. + ("xx-YY", "New app · Sep 7, 14:02"), + ], +) +def test_the_fallback_is_localized(language: str, expected: str): + assert naming.fallback_app_name(NOW, language) == expected + + +def test_every_console_locale_has_a_fallback_stem(): + from constants.languages import languages + + assert set(languages) <= set(naming._FALLBACK_STEM) + + +def test_normalize_proposed_name_unwraps_and_caps_a_model_answer(): + assert naming.normalize_proposed_name(' "Expense Reimbursement Approval" ') == "Expense Reimbursement Approval" + + +@pytest.mark.parametrize("proposal", ["", " ", '""', "!!!"]) +def test_normalize_proposed_name_yields_nothing_for_an_unusable_answer(proposal: str): + # "" tells the caller to keep the name already derived from the prompt. + assert naming.normalize_proposed_name(proposal) == "" diff --git a/api/tests/unit_tests/core/dify_builder/test_ports.py b/api/tests/unit_tests/core/dify_builder/test_ports.py index 844e6b954c8..6abd6961bdf 100644 --- a/api/tests/unit_tests/core/dify_builder/test_ports.py +++ b/api/tests/unit_tests/core/dify_builder/test_ports.py @@ -116,6 +116,9 @@ class _StubAgent: def analyze_goal(self, _goal_text): return {} + def propose_app_name(self, _goal_text, _requirements): + return "" + def propose_plan_v1(self, _requirements): return [] diff --git a/api/tests/unit_tests/services/dify_builder/test_app_naming.py b/api/tests/unit_tests/services/dify_builder/test_app_naming.py new file mode 100644 index 00000000000..9514b9e3afc --- /dev/null +++ b/api/tests/unit_tests/services/dify_builder/test_app_naming.py @@ -0,0 +1,80 @@ +"""Builder-driven app rename: usable proposals only, and never fatal.""" + +import contextlib +from unittest.mock import MagicMock, patch + +import pytest + +from services.dify_builder import app_naming + + +@pytest.fixture +def db_stubbed(): + """Stub the engine so nothing here needs a Flask app context. + + Without it the real ``db.engine`` raises, ``rename_app_from_proposal`` + swallows that, and every assertion below passes for the wrong reason. + """ + with ( + patch.object(app_naming, "db", MagicMock()), + patch.object(app_naming, "sessionmaker") as factory, + ): + factory.return_value.return_value = contextlib.nullcontext(MagicMock(name="session")) + yield factory + + +def test_an_unusable_proposal_never_touches_the_database(db_stubbed): + # No session is opened at all -- the derived name simply stands. + app_naming.rename_app_from_proposal(app_id="a", tenant_id="t", account_id="acc", proposed=' "" ') + db_stubbed.assert_not_called() + + +@pytest.mark.usefixtures("db_stubbed") +def test_a_usable_proposal_is_normalized_before_it_is_stored(): + app = MagicMock() + app.name = "Set up an expense" + with ( + patch.object(app_naming, "AppService") as app_service, + patch.object(app_naming, "_load_app", return_value=app), + ): + app_naming.rename_app_from_proposal( + app_id="app-1", tenant_id="tenant-1", account_id="acc-1", proposed=' "Expense approval"\n' + ) + app_service.return_value.rename_app.assert_called_once() + args, kwargs = app_service.return_value.rename_app.call_args + assert args[1] == "Expense approval" + assert kwargs["account_id"] == "acc-1" + + +@pytest.mark.usefixtures("db_stubbed") +def test_a_proposal_equal_to_the_current_name_is_not_written(): + app = MagicMock() + app.name = "Expense approval" + with ( + patch.object(app_naming, "AppService") as app_service, + patch.object(app_naming, "_load_app", return_value=app), + ): + app_naming.rename_app_from_proposal( + app_id="app-1", tenant_id="tenant-1", account_id="acc-1", proposed="Expense approval" + ) + app_service.return_value.rename_app.assert_not_called() + + +@pytest.mark.usefixtures("db_stubbed") +def test_a_missing_app_is_not_an_error(): + with ( + patch.object(app_naming, "AppService") as app_service, + patch.object(app_naming, "_load_app", return_value=None), + ): + app_naming.rename_app_from_proposal(app_id="gone", tenant_id="t", account_id="acc", proposed="Anything") + app_service.return_value.rename_app.assert_not_called() + + +@pytest.mark.usefixtures("db_stubbed") +def test_a_failing_rename_is_swallowed(): + # A cosmetic rename must never take the build down with it. + with ( + patch.object(app_naming, "AppService", side_effect=RuntimeError("boom")), + patch.object(app_naming, "_load_app", return_value=MagicMock()), + ): + app_naming.rename_app_from_proposal(app_id="a", tenant_id="t", account_id="acc", proposed="Expense approval") diff --git a/api/tests/unit_tests/services/dify_builder/test_card_options.py b/api/tests/unit_tests/services/dify_builder/test_card_options.py new file mode 100644 index 00000000000..8b8a9854508 --- /dev/null +++ b/api/tests/unit_tests/services/dify_builder/test_card_options.py @@ -0,0 +1,137 @@ +"""A gate's choice as card options plus one fixed button pair. + +The action bar used to change shape per gate -- four buttons at review, one at +plan approval. These project the same choice, identically everywhere. +""" + +import pytest + +from core.dify_builder.contract import CANCEL_ACTION_ID, CONFIRM_ACTION_ID, ActionKind +from core.dify_builder.state import PcState +from services.dify_builder.service import ( + _actions_for, + _decision_for, + resolve_action_kind, + resolve_submitted_action, +) + + +def _decision(state: PcState): + return _decision_for(_actions_for(state)) + + +def test_a_gate_offers_its_actions_as_options_not_buttons(): + decision = _decision(PcState.BUILD_REVIEW) + + assert [o.id for o in decision.options] == [ + "publish_workflow", + "keep_draft", + "continue_adjusting", + "revert", + ] + # Whatever the gate, the buttons are the same two. + assert decision.confirm.id == CONFIRM_ACTION_ID + assert decision.cancel.id == CANCEL_ACTION_ID + + +def test_exactly_one_option_carries_the_default_badge(): + decision = _decision(PcState.BUILD_REVIEW) + + defaults = [o.id for o in decision.options if o.is_default] + assert defaults == ["publish_workflow"] + assert decision.default_option_id == "publish_workflow" + + +def test_a_destructive_action_keeps_its_tone_as_an_option(): + decision = _decision(PcState.BUILD_REVIEW) + + tones = {o.id: o.tone for o in decision.options} + assert tones["revert"] == "destructive" + assert tones["keep_draft"] == "neutral" + + +def test_an_option_carries_the_state_map_hints_its_action_had(): + decision = _decision(PcState.BUILD_REVIEW) + + publish = next(o for o in decision.options if o.id == "publish_workflow") + assert publish.next_state == "build.publish" + assert publish.canvas_event == "publish_workflow" + + +def test_a_single_action_gate_still_becomes_an_option(): + decision = _decision(PcState.BUILD_PLAN_APPROVAL) + + assert [o.id for o in decision.options] == ["approve_plan"] + assert decision.options[0].is_default is True + + +def test_a_gate_offering_nothing_has_no_decision(): + # Nothing to choose means no card options and no buttons -- not an empty row. + assert _decision_for([]) is None + + +def test_rejecting_a_repair_asks_for_a_reason(): + decision = _decision(PcState.FIX_AWAIT_APPROVAL) + + reject = next(o for o in decision.options if o.id == "reject_repair") + assert reject.input is not None + assert (reject.input.min_length, reject.input.max_length) == (10, 200) + assert reject.input.required is True + + +def test_options_without_free_text_declare_none(): + decision = _decision(PcState.FIX_AWAIT_APPROVAL) + + approve = next(o for o in decision.options if o.id == "approve_plan") + assert approve.input is None + + +def test_an_automatic_action_is_never_offered_as_an_option(): + from core.dify_builder.contract import Action as UiAction + + decision = _decision_for( + [ + UiAction(id="visible", label="Visible", kind=ActionKind.PRIMARY), + UiAction(id="auto", label="Auto", kind=ActionKind.AUTOMATIC), + ] + ) + + assert [o.id for o in decision.options] == ["visible"] + + +def test_only_the_first_primary_is_the_default(): + from core.dify_builder.contract import Action as UiAction + + decision = _decision_for( + [ + UiAction(id="first", label="First", kind=ActionKind.PRIMARY), + UiAction(id="second", label="Second", kind=ActionKind.PRIMARY), + ] + ) + + # Two recommendations is no recommendation. + assert [o.id for o in decision.options if o.is_default] == ["first"] + + +class TestResolvingWhatTheClientPosted: + def test_confirm_resolves_through_the_named_option(self): + # "publish_fix" is renamed to the handler kind "publish"; the option id + # goes through exactly the map the button id used to. + assert resolve_submitted_action(CONFIRM_ACTION_ID, {"option_id": "publish_fix"}) == "publish" + + def test_an_option_id_that_is_already_a_kind_passes_through(self): + assert resolve_submitted_action(CONFIRM_ACTION_ID, {"option_id": "publish_workflow"}) == "publish_workflow" + + def test_a_legacy_client_posting_the_action_id_still_resolves(self): + assert resolve_submitted_action("publish_fix", {}) == "publish" + + def test_both_contracts_land_on_the_same_kind(self): + for action_id in ("publish_fix", "run_validation", "revert", "restart"): + assert resolve_submitted_action(CONFIRM_ACTION_ID, {"option_id": action_id}) == resolve_action_kind( + action_id + ) + + @pytest.mark.parametrize("payload", [{}, None, {"option_id": ""}, {"option_id": None}, "not-a-dict"]) + def test_confirm_naming_no_option_resolves_to_nothing(self, payload): + # "" is not legal in any state, so the existing per-state check rejects it. + assert resolve_submitted_action(CONFIRM_ACTION_ID, payload) == "" diff --git a/api/tests/unit_tests/services/dify_builder/test_serde_app_name_auto.py b/api/tests/unit_tests/services/dify_builder/test_serde_app_name_auto.py new file mode 100644 index 00000000000..478a2be7c5d --- /dev/null +++ b/api/tests/unit_tests/services/dify_builder/test_serde_app_name_auto.py @@ -0,0 +1,19 @@ +"""The one-shot app-name refine flag survives the commit round-trip.""" + +from core.dify_builder.models import DifyBuilderContext +from services.dify_builder.serde import context_from_dict, context_to_dict + + +def test_app_name_auto_defaults_off(): + # Off by default: an app is only renamed when its creator asked for it. + assert DifyBuilderContext().app_name_auto is False + + +def test_app_name_auto_round_trips(): + fc = DifyBuilderContext(app_name_auto=True) + assert context_from_dict(context_to_dict(fc)).app_name_auto is True + + +def test_app_name_auto_absent_in_an_older_row_defaults_off(): + # Sessions started before this field must not suddenly rename their apps. + assert context_from_dict({"goal_text": "x"}).app_name_auto is False diff --git a/api/tests/unit_tests/services/dify_builder/test_service.py b/api/tests/unit_tests/services/dify_builder/test_service.py index f35e93a6650..8d1cce45aa6 100644 --- a/api/tests/unit_tests/services/dify_builder/test_service.py +++ b/api/tests/unit_tests/services/dify_builder/test_service.py @@ -238,6 +238,27 @@ def test_create_build_session_rejects_blank_goal(service: DifyBuilderService, en assert enqueued == [] +@pytest.mark.parametrize(("derive", "expected"), [(True, True), (False, False)]) +def test_create_build_session_records_whether_builder_may_rename_the_app( + service: DifyBuilderService, repo: SqlDifyBuilderRepository, derive: bool, expected: bool +) -> None: + # The client knows which entry it came from: the homepage prompt (Builder + # renames once) or create-from-blank (the user already named it). + view = service.create_build_session(APP_ID, _actor(), goal_text="Build it", derive_app_name=derive) + + _session, fc = repo.get_session(view.session_id) + assert fc.app_name_auto is expected + + +def test_create_build_session_does_not_rename_by_default( + service: DifyBuilderService, repo: SqlDifyBuilderRepository +) -> None: + view = service.create_build_session(APP_ID, _actor(), goal_text="Build it") + + _session, fc = repo.get_session(view.session_id) + assert fc.app_name_auto is False + + def test_create_fix_session_converts_valid_checklist_dicts_before_persisting( service: DifyBuilderService, repo: SqlDifyBuilderRepository ) -> None: @@ -772,6 +793,32 @@ def test_get_session_view_actions_for_fix_await_decision( ] +def test_session_view_carries_the_same_choice_as_options_and_as_actions( + service: DifyBuilderService, repo: SqlDifyBuilderRepository +) -> None: + s = _seed_session_at(repo, PcState.FIX_AWAIT_DECISION) + + view = service.get_session_view(s.id, _actor()) + + # Both contracts are live while the web client migrates, and they must never + # disagree about what this gate is asking. + assert [o.id for o in view.decision.options] == [a.id for a in view.actions] + assert view.decision.default_option_id == "publish_fix" + assert (view.decision.confirm.id, view.decision.cancel.id) == ("confirm", "cancel") + + +def test_a_session_view_with_nothing_to_decide_has_no_decision( + service: DifyBuilderService, repo: SqlDifyBuilderRepository +) -> None: + s = _seed_session_at(repo, PcState.BUILD_COMPLETE) + + view = service.get_session_view(s.id, _actor()) + + # A finished session asks nothing, so there is no card to put options in. + assert view.actions == [] + assert view.decision is None + + def test_get_session_view_actions_for_fix_await_verify( service: DifyBuilderService, repo: SqlDifyBuilderRepository ) -> None: diff --git a/api/tests/unit_tests/services/dify_builder/test_wiring.py b/api/tests/unit_tests/services/dify_builder/test_wiring.py index 4ebb08f1baf..15f065ffeb3 100644 --- a/api/tests/unit_tests/services/dify_builder/test_wiring.py +++ b/api/tests/unit_tests/services/dify_builder/test_wiring.py @@ -39,6 +39,7 @@ def test_session_view_to_dict_round_trips_fields(): "entry_mode": "fix", "phase": "understand", "actions": [], + "decision": None, "active_interaction": None, "checkpoint": None, "recovery": None, diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index d568fb6742c..1f108f4241d 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -1038,3 +1038,49 @@ class TestAgentAppType: assert exc_info.value is error retire_unowned.assert_not_called() enqueue_collection.assert_not_called() + + +class TestResolveAppName: + """Naming an app created from a goal prompt (App Builder spec, board N).""" + + @staticmethod + def _account(*, timezone: str | None = "UTC", language: str | None = "en-US") -> Account: + account = Account(name="Namer", email=f"namer-{uuid4()}@example.com") + account.timezone = timezone + account.interface_language = language + return account + + def test_a_user_supplied_name_wins_over_the_prompt(self): + # Create-from-blank: the user names the app and Builder only inherits it. + params = CreateAppParams(name="Refund approval", prompt="Something else entirely", mode="workflow") + assert AppService._resolve_app_name(params, self._account()) == "Refund approval" + + def test_the_name_is_derived_from_the_prompt_when_omitted(self): + params = CreateAppParams(prompt="Refund approval for ecommerce orders", mode="workflow") + assert AppService._resolve_app_name(params, self._account()) == "Refund approval for ecommerce" + + def test_a_prompt_with_nothing_nameable_falls_back_in_the_console_language(self): + params = CreateAppParams(prompt="???", mode="workflow") + name = AppService._resolve_app_name(params, self._account(language="zh-Hans")) + assert name.startswith("新应用 · ") + + def test_neither_name_nor_prompt_still_names_the_app(self): + # Creation must never fail for want of a name. + params = CreateAppParams(mode="workflow") + assert AppService._resolve_app_name(params, self._account()).startswith("New app · ") + + def test_an_unparseable_account_timezone_does_not_break_creation(self): + params = CreateAppParams(prompt=" ", mode="workflow") + name = AppService._resolve_app_name(params, self._account(timezone="Mars/Olympus_Mons")) + assert name.startswith("New app · ") + + def test_the_fallback_stamp_uses_the_account_timezone(self): + params = CreateAppParams(prompt="!!!", mode="workflow") + tokyo = AppService._resolve_app_name(params, self._account(timezone="Asia/Tokyo")) + honolulu = AppService._resolve_app_name(params, self._account(timezone="Pacific/Honolulu")) + # 19 hours apart: the two stamps can never render the same clock time. + assert tokyo != honolulu + + def test_only_the_opening_clause_of_a_long_prompt_is_scanned(self): + params = CreateAppParams(prompt="Refund approval for ecommerce orders. " + "x" * 50_000, mode="workflow") + assert AppService._resolve_app_name(params, self._account()) == "Refund approval for ecommerce"