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>
This commit is contained in:
Mike Fortman 2025-06-18 09:24:39 -05:00 • committed by GitHub
commit ddc17d4c77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 261 additions and 48 deletions

View file

@ -1,9 +1,9 @@
import base64 import base64
import json import json
import os import os
import re
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from string import Formatter
import yaml import yaml
from langchain_core.language_models import BaseLanguageModel 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]: def extract_input_variables_from_prompt(prompt: str) -> list[str]:
variables = [] """Extract variable names from a prompt string using Python's built-in string formatter.
remaining_text = prompt
# Pattern to match single {var} and double {{var}} braces. Uses the same convention as Python's .format() method:
pattern = r"\{\{(.*?)\}\}|\{([^{}]+)\}" - 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: # Use local bindings for micro-optimization
match = re.search(pattern, remaining_text) variables_append = variables.append
if not match: seen_add = seen.add
break seen_contains = seen.__contains__
# Extract the variable name from either the single or double brace match. for _, field_name, _, _ in formatter.parse(prompt):
# If match found in double braces, re-add single braces for JSON strings. if field_name and not seen_contains(field_name):
variable_name = "{{" + match.group(1) + "}}" if match.group(1) else match.group(2) variables_append(field_name)
if variable_name is not None: seen_add(field_name)
# 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
return variables return variables

View file

@ -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)

View file

@ -26,21 +26,33 @@ export default function PromptAreaComponent({
readonly = false, readonly = false,
}: InputProps<string, PromptAreaComponentType>): JSX.Element { }: InputProps<string, PromptAreaComponentType>): JSX.Element {
const coloredContent = (typeof value === "string" ? value : "") const coloredContent = (typeof value === "string" ? value : "")
// escape HTML first
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(regexHighlight, (match, p1, p2) => { // highlight variables
// Decide which group was matched. If p1 is not undefined, do nothing .replace(regexHighlight, (match, codeFence, openRun, varName, closeRun) => {
// we don't want to change the text. If p2 is not undefined, then we // 1) Leave ```code``` blocks untouched
// have a variable, so we should highlight it. if (codeFence) return match;
// ! This will not work with multiline or indented json yet
if (p1 !== undefined) {
return match;
} else if (p2 !== undefined) {
return `<span class="chat-message-highlight">{${p2}}</span>`;
}
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}` +
`<span class="chat-message-highlight">{${varName}}</span>` +
`${outerRight}`
);
}) })
// preserve new-lines
.replace(/\n/g, "<br />"); .replace(/\n/g, "<br />");
const renderPromptText = () => ( const renderPromptText = () => (

View file

@ -37,7 +37,13 @@ export const INVALID_CHARACTERS = [
* It matches the variables in the text that are between {{}} or {}. * 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 specialCharsRegex = /[!@#$%^&*()\-_=+[\]{}|;:'",.<>/?\\`´]/;
export const programmingLanguages: languageMap = { export const programmingLanguages: languageMap = {

View file

@ -52,11 +52,18 @@ export default function PromptModal({
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
function checkVariables(valueToCheck: string): void { function checkVariables(valueToCheck: string): void {
const regex = /\{([^{}]+)\}/g; // Match *any* brace run around an identifier
const regex = /(\{+)([^{}]+)(\}+)/g;
const matches: string[] = []; const matches: string[] = [];
let match; let match;
while ((match = regex.exec(valueToCheck))) { 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[] = []; let invalid_chars: string[] = [];
@ -88,18 +95,22 @@ export default function PromptModal({
const coloredContent = (typeof inputValue === "string" ? inputValue : "") const coloredContent = (typeof inputValue === "string" ? inputValue : "")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(regexHighlight, (match, p1, p2) => { .replace(regexHighlight, (match, openRun, varName, closeRun) => {
// Decide which group was matched. If p1 is not undefined, do nothing // 1) Only highlight when both sides are the *same* length and that
// we don't want to change the text. If p2 is not undefined, then we // length is odd ( 1,3,5,… ).
// have a variable, so we should highlight it. const lenOpen = openRun?.length ?? 0;
// ! This will not work with multiline or indented json yet const lenClose = closeRun?.length ?? 0;
if (p1 !== undefined) { const isVariable = lenOpen === lenClose && lenOpen % 2 === 1;
return match;
} else if (p2 !== undefined) {
return varHighlightHTML({ name: p2 });
}
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, "<br />"); .replace(/\n/g, "<br />");

View file

@ -2,6 +2,136 @@ import { expect, test } from "@playwright/test";
import { adjustScreenView } from "../../utils/adjust-screen-view"; import { adjustScreenView } from "../../utils/adjust-screen-view";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
import { zoomOut } from "../../utils/zoom-out"; 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( test(
"PromptTemplateComponent", "PromptTemplateComponent",
{ tag: ["@release", "@workspace"] }, { tag: ["@release", "@workspace"] },