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:
parent
2d34bc3b37
commit
ddc17d4c77
6 changed files with 261 additions and 48 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
60
src/backend/tests/unit/utils/test_interface_utils.py
Normal file
60
src/backend/tests/unit/utils/test_interface_utils.py
Normal 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)
|
||||||
|
|
@ -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, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.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 = () => (
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.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 />");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"] },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue