From ddc17d4c776e450657dbbf64cb6ed48c9ed2f78f Mon Sep 17 00:00:00 2001 From: Mike Fortman Date: Wed, 18 Jun 2025 09:24:39 -0500 Subject: [PATCH] fix: Match front and backend prompt variable behavior (#8522) * fix handling of braces in front and back end * add tests * ruff check fix * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes * address pr comments and switches to use formatter * remove code fence that isn't working * comment * [autofix.ci] apply automated fixes * text fix * style fix * test fix --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- src/backend/base/langflow/interface/utils.py | 40 +++--- .../tests/unit/utils/test_interface_utils.py | 60 ++++++++ .../components/promptComponent/index.tsx | 34 +++-- src/frontend/src/constants/constants.ts | 8 +- src/frontend/src/modals/promptModal/index.tsx | 37 +++-- .../core/unit/promptModalComponent.spec.ts | 130 ++++++++++++++++++ 6 files changed, 261 insertions(+), 48 deletions(-) create mode 100644 src/backend/tests/unit/utils/test_interface_utils.py diff --git a/src/backend/base/langflow/interface/utils.py b/src/backend/base/langflow/interface/utils.py index ed2a395d5..5963d03d4 100644 --- a/src/backend/base/langflow/interface/utils.py +++ b/src/backend/base/langflow/interface/utils.py @@ -1,9 +1,9 @@ import base64 import json import os -import re from io import BytesIO from pathlib import Path +from string import Formatter import yaml from langchain_core.language_models import BaseLanguageModel @@ -60,31 +60,25 @@ def try_setting_streaming_options(langchain_object): def extract_input_variables_from_prompt(prompt: str) -> list[str]: - variables = [] - remaining_text = prompt + """Extract variable names from a prompt string using Python's built-in string formatter. - # Pattern to match single {var} and double {{var}} braces. - pattern = r"\{\{(.*?)\}\}|\{([^{}]+)\}" + Uses the same convention as Python's .format() method: + - Single braces {name} are variable placeholders + - Double braces {{name}} are escape sequences that render as literal {name} + """ + formatter = Formatter() + variables: list[str] = [] + seen: set[str] = set() - while True: - match = re.search(pattern, remaining_text) - if not match: - break + # Use local bindings for micro-optimization + variables_append = variables.append + seen_add = seen.add + seen_contains = seen.__contains__ - # Extract the variable name from either the single or double brace match. - # If match found in double braces, re-add single braces for JSON strings. - variable_name = "{{" + match.group(1) + "}}" if match.group(1) else match.group(2) - if variable_name is not None: - # This means there is a match - # but there is nothing inside the braces - variables.append(variable_name) - - # Remove the matched text from the remaining_text - start, end = match.span() - remaining_text = remaining_text[:start] + remaining_text[end:] - - # Proceed to the next match until no more matches are found - # No need to compare remaining "{}" instances because we are re-adding braces for JSON compatibility + for _, field_name, _, _ in formatter.parse(prompt): + if field_name and not seen_contains(field_name): + variables_append(field_name) + seen_add(field_name) return variables diff --git a/src/backend/tests/unit/utils/test_interface_utils.py b/src/backend/tests/unit/utils/test_interface_utils.py new file mode 100644 index 000000000..e1d7e7e85 --- /dev/null +++ b/src/backend/tests/unit/utils/test_interface_utils.py @@ -0,0 +1,60 @@ +import pytest +from langflow.interface.utils import extract_input_variables_from_prompt + + +@pytest.mark.parametrize( + ("prompt", "expected"), + [ + # Basic variable extraction + ("Hello {name}!", ["name"]), + ("Hi {name}, you are {age} years old", ["name", "age"]), + # Empty prompt + ("", []), + ("No variables here", []), + # Duplicate variables + ("Hello {name}! How are you {name}?", ["name"]), + # Whitespace handling - Formatter preserves whitespace in field names + ("Hello { name }!", [" name "]), + ("Hi { name }, bye", [" name "]), + # Multiple braces (escaping) + ("Escaped {{not_a_var}}", []), + ("Mixed {{escaped}} and {real_var}", ["real_var"]), + ("Double escaped {{{{not_this}}}}", []), + # Complex cases + ("Hello {name}! Your score is {{4 + 5}}, age: {age}", ["name", "age"]), + ("Nested {{obj['key']}} with {normal_var}", ["normal_var"]), + ("Template {{user.name}} with {id} and {type}", ["id", "type"]), + # Edge cases + ("{single}", ["single"]), + ("{{double}}", []), + ("{{{}}}", []), + # Multiple variables with various spacing + ( + """ + Multi-line with {var1} + and {var2} plus + {var3} at the end + """, + ["var1", "var2", "var3"], + ), + ], +) +def test_extract_input_variables(prompt, expected): + """Test the extract_input_variables_from_prompt function with various cases.""" + result = extract_input_variables_from_prompt(prompt) + assert sorted(result) == sorted(expected), f"Failed for prompt: {prompt}" + + +@pytest.mark.parametrize( + ("prompt", "expected_error"), + [ + # Malformed format strings that should raise ValueError + ("}{", "Single '}' encountered in format string"), + ("{incomplete", "expected '}' before end of string"), + ("incomplete}", "Single '}' encountered in format string"), + ], +) +def test_extract_input_variables_malformed(prompt, expected_error): + """Test that malformed format strings raise ValueError.""" + with pytest.raises(ValueError, match=expected_error): + extract_input_variables_from_prompt(prompt) diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/promptComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/promptComponent/index.tsx index 3747b2d43..e19cdff77 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/promptComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/promptComponent/index.tsx @@ -26,21 +26,33 @@ export default function PromptAreaComponent({ readonly = false, }: InputProps): JSX.Element { const coloredContent = (typeof value === "string" ? value : "") + // escape HTML first .replace(//g, ">") - .replace(regexHighlight, (match, p1, p2) => { - // Decide which group was matched. If p1 is not undefined, do nothing - // we don't want to change the text. If p2 is not undefined, then we - // have a variable, so we should highlight it. - // ! This will not work with multiline or indented json yet - if (p1 !== undefined) { - return match; - } else if (p2 !== undefined) { - return `{${p2}}`; - } + // highlight variables + .replace(regexHighlight, (match, codeFence, openRun, varName, closeRun) => { + // 1) Leave ```code``` blocks untouched + if (codeFence) return match; - return match; + // 2) Balanced & odd-length brace runs mean “real variable” + const lenOpen = openRun?.length ?? 0; + const lenClose = closeRun?.length ?? 0; + const isVariable = lenOpen === lenClose && lenOpen % 2 === 1; + + if (!isVariable) return match; // even runs are just escapes + + // 3) Number of literal braces outside the span + const outerCount = Math.floor(lenOpen / 2); + const outerLeft = "{".repeat(outerCount); + const outerRight = "}".repeat(outerCount); + + return ( + `${outerLeft}` + + `{${varName}}` + + `${outerRight}` + ); }) + // preserve new-lines .replace(/\n/g, "
"); const renderPromptText = () => ( diff --git a/src/frontend/src/constants/constants.ts b/src/frontend/src/constants/constants.ts index 2ae093ea8..f39a0623d 100644 --- a/src/frontend/src/constants/constants.ts +++ b/src/frontend/src/constants/constants.ts @@ -37,7 +37,13 @@ export const INVALID_CHARACTERS = [ * It matches the variables in the text that are between {{}} or {}. */ -export const regexHighlight = /\{\{(.*?)\}\}|\{([^{}]+)\}/g; +/** + * p1 – fenced code block ```...``` + * p2 – opening brace run (one or more) + * p3 – variable name (no braces) + * p4 – closing brace run (one or more) + */ +export const regexHighlight = /(```[\s\S]*?```)|(\{+)([^{}]+)(\}+)/g; export const specialCharsRegex = /[!@#$%^&*()\-_=+[\]{}|;:'",.<>/?\\`´]/; export const programmingLanguages: languageMap = { diff --git a/src/frontend/src/modals/promptModal/index.tsx b/src/frontend/src/modals/promptModal/index.tsx index a8a85f246..ba2a17e29 100644 --- a/src/frontend/src/modals/promptModal/index.tsx +++ b/src/frontend/src/modals/promptModal/index.tsx @@ -52,11 +52,18 @@ export default function PromptModal({ const textareaRef = useRef(null); function checkVariables(valueToCheck: string): void { - const regex = /\{([^{}]+)\}/g; + // Match *any* brace run around an identifier + const regex = /(\{+)([^{}]+)(\}+)/g; const matches: string[] = []; let match; + while ((match = regex.exec(valueToCheck))) { - matches.push(`{${match[1]}}`); + const [openRun, varName, closeRun] = [match[1], match[2], match[3]]; + + // keep only odd, balanced runs (actual variables) + if (openRun.length === closeRun.length && openRun.length % 2 === 1) { + matches.push(`{${varName}}`); // normalise to single-brace form + } } let invalid_chars: string[] = []; @@ -88,18 +95,22 @@ export default function PromptModal({ const coloredContent = (typeof inputValue === "string" ? inputValue : "") .replace(//g, ">") - .replace(regexHighlight, (match, p1, p2) => { - // Decide which group was matched. If p1 is not undefined, do nothing - // we don't want to change the text. If p2 is not undefined, then we - // have a variable, so we should highlight it. - // ! This will not work with multiline or indented json yet - if (p1 !== undefined) { - return match; - } else if (p2 !== undefined) { - return varHighlightHTML({ name: p2 }); - } + .replace(regexHighlight, (match, openRun, varName, closeRun) => { + // 1) Only highlight when both sides are the *same* length and that + // length is odd ( 1,3,5,… ). + const lenOpen = openRun?.length ?? 0; + const lenClose = closeRun?.length ?? 0; + const isVariable = lenOpen === lenClose && lenOpen % 2 === 1; - return match; + if (!isVariable) return match; // even-brace runs ⇒ escape, no highlight + + // 2) Number of literal braces each side = floor(lenOpen / 2) + const literal = "{".repeat(Math.floor(lenOpen / 2)); + return ( + literal + + varHighlightHTML({ name: varName }) + + literal.replace(/\{/g, "}") // same amount of closing braces + ); }) .replace(/\n/g, "
"); diff --git a/src/frontend/tests/core/unit/promptModalComponent.spec.ts b/src/frontend/tests/core/unit/promptModalComponent.spec.ts index b0d99ae66..4383ca830 100644 --- a/src/frontend/tests/core/unit/promptModalComponent.spec.ts +++ b/src/frontend/tests/core/unit/promptModalComponent.spec.ts @@ -2,6 +2,136 @@ import { expect, test } from "@playwright/test"; import { adjustScreenView } from "../../utils/adjust-screen-view"; import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; import { zoomOut } from "../../utils/zoom-out"; + +// Helper function to verify prompt variables +async function verifyPromptVariables( + page, + template: string, + expectedVars: string[], + isFirstTime = true, +) { + await page.getByTestId("promptarea_prompt_template").click(); + + // Use different selectors based on whether this is the first time or a subsequent edit + if (isFirstTime) { + await page.getByTestId("modal-promptarea_prompt_template").fill(template); + + // Verify the template is set correctly + const value = await page + .getByTestId("modal-promptarea_prompt_template") + .inputValue(); + expect(value).toBe(template); + } else { + // For subsequent edits, we need to click the edit button first + await page.getByRole("dialog").getByTestId("edit-prompt-sanitized").click(); + await page.getByTestId("modal-promptarea_prompt_template").fill(template); + + // Verify the template is set correctly + const value = await page + .getByTestId("modal-promptarea_prompt_template") + .inputValue(); + expect(value).toBe(template); + } + + // Verify each expected variable has a badge + for (let i = 0; i < expectedVars.length; i++) { + const badgeText = await page.locator(`//*[@id="badge${i}"]`).innerText(); + expect(badgeText).toBe(expectedVars[i]); + } + + // Verify no extra badges exist + const extraBadge = await page + .locator(`//*[@id="badge${expectedVars.length}"]`) + .isVisible() + .catch(() => false); + expect(extraBadge).toBeFalsy(); + + await page.getByTestId("genericModalBtnSave").click(); +} + +test( + "PromptTemplateComponent - Variable Extraction", + { tag: ["@release", "@workspace"] }, + async ({ page }) => { + await awaitBootstrapTest(page); + await page.waitForSelector('[data-testid="blank-flow"]', { + timeout: 30000, + }); + await page.getByTestId("blank-flow").click(); + await page.getByTestId("sidebar-search-input").click(); + await page.getByTestId("sidebar-search-input").fill("prompt"); + await page.waitForSelector('[data-testid="promptsPrompt"]', { + timeout: 3000, + }); + + await page + .locator('//*[@id="promptsPrompt"]') + .dragTo(page.locator('//*[@id="react-flow-id"]')); + await page.mouse.up(); + await page.mouse.down(); + await adjustScreenView(page); + + // Test basic variable extraction (first time) + await verifyPromptVariables(page, "Hello {name}!", ["name"], true); + + // Test multiple variables (subsequent edit) + await verifyPromptVariables( + page, + "Hi {name}, you are {age} years old", + ["name", "age"], + false, + ); + + // Test duplicate variables (should only show once) + await verifyPromptVariables( + page, + "Hello {name}! How are you {name}?", + ["name"], + false, + ); + + // Test escaped variables with {{}} + await verifyPromptVariables( + page, + "Escaped {{not_a_var}} but {real_var} works", + ["real_var"], + false, + ); + + // Test complex template + await verifyPromptVariables( + page, + "Hello {name}! Your score is {{4 + 5}}, age: {age}", + ["name", "age"], + false, + ); + + // Test multiline template + await verifyPromptVariables( + page, + `Multi-line with {var1} + and {var2} plus + {var3} at the end`, + ["var1", "var2", "var3"], + false, + ); + + // Final verification - check that the template persists + await page.getByTestId("div-generic-node").click(); + await page.getByTestId("edit-button-modal").last().click(); + + const savedTemplate = await page + .locator('//*[@id="promptarea_prompt_edit_template"]') + .innerText(); + expect(savedTemplate).toBe( + "Multi-line with {var1}\n and {var2} plus\n {var3} at the end", + ); + + // Close the final modal + await page.getByText("Close").last().click(); + }, +); + test( "PromptTemplateComponent", { tag: ["@release", "@workspace"] },