Lifei/UI parameter input (#5222)

This commit is contained in:
Lifei Zhou
2025-10-20 10:05:54 +11:00
committed by GitHub
parent 6c3e07e9c7
commit 8b53a5696e
27 changed files with 717 additions and 1250 deletions
+17 -25
View File
@@ -1,4 +1,4 @@
use crate::recipe::read_recipe_file_content::{read_parameter_file_content, RecipeFile};
use crate::recipe::read_recipe_file_content::read_parameter_file_content;
use crate::recipe::template_recipe::render_recipe_content_with_params;
use crate::recipe::validate_recipe::validate_recipe_template_from_content;
use crate::recipe::{
@@ -19,33 +19,26 @@ pub enum RecipeError {
RecipeParsing { source: anyhow::Error },
}
pub fn render_recipe_template<F>(
recipe_file: RecipeFile,
fn render_recipe_template<F>(
recipe_content: String,
recipe_dir: &Path,
params: Vec<(String, String)>,
user_prompt_fn: Option<F>,
) -> Result<(String, Vec<String>)>
where
F: Fn(&str, &str) -> Result<String, anyhow::Error>,
{
let RecipeFile {
content: recipe_file_content,
parent_dir: recipe_parent_dir,
..
} = recipe_file;
let recipe_dir_str = recipe_parent_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
let recipe_parameters = validate_recipe_template_from_content(
&recipe_file_content,
Some(recipe_dir_str.to_string()),
)?
.parameters;
let recipe_dir_str = recipe_dir.display().to_string();
let recipe_parameters =
validate_recipe_template_from_content(&recipe_content, Some(recipe_dir_str.clone()))?
.parameters;
let (params_for_template, missing_params) =
apply_values_to_parameters(&params, recipe_parameters, recipe_dir_str, user_prompt_fn)?;
apply_values_to_parameters(&params, recipe_parameters, &recipe_dir_str, user_prompt_fn)?;
let rendered_content = if missing_params.is_empty() {
render_recipe_content_with_params(&recipe_file_content, &params_for_template)?
render_recipe_content_with_params(&recipe_content, &params_for_template)?
} else {
String::new()
};
@@ -54,16 +47,16 @@ where
}
pub fn build_recipe_from_template<F>(
recipe_file: RecipeFile,
recipe_content: String,
recipe_dir: &Path,
params: Vec<(String, String)>,
user_prompt_fn: Option<F>,
) -> Result<Recipe, RecipeError>
where
F: Fn(&str, &str) -> Result<String, anyhow::Error>,
{
let recipe_parent_dir = recipe_file.parent_dir.clone();
let (rendered_content, missing_params) =
render_recipe_template(recipe_file, params.clone(), user_prompt_fn)
render_recipe_template(recipe_content, recipe_dir, params.clone(), user_prompt_fn)
.map_err(|source| RecipeError::TemplateRendering { source })?;
if !missing_params.is_empty() {
@@ -77,8 +70,7 @@ where
if let Some(ref mut sub_recipes) = recipe.sub_recipes {
for sub_recipe in sub_recipes {
if let Ok(resolved_path) = resolve_sub_recipe_path(&sub_recipe.path, &recipe_parent_dir)
{
if let Ok(resolved_path) = resolve_sub_recipe_path(&sub_recipe.path, recipe_dir) {
sub_recipe.path = resolved_path;
}
}
@@ -90,7 +82,7 @@ where
pub fn apply_values_to_parameters<F>(
user_params: &[(String, String)],
recipe_parameters: Option<Vec<RecipeParameter>>,
recipe_parent_dir: &str,
recipe_dir: &str,
user_prompt_fn: Option<F>,
) -> Result<(HashMap<String, String>, Vec<String>)>
where
@@ -99,7 +91,7 @@ where
let mut param_map: HashMap<String, String> = user_params.iter().cloned().collect();
param_map.insert(
BUILT_IN_RECIPE_DIR_PARAM.to_string(),
recipe_parent_dir.to_string(),
recipe_dir.to_string(),
);
let mut missing_params: Vec<String> = Vec::new();
for param in recipe_parameters.unwrap_or_default() {
+73 -34
View File
@@ -3,12 +3,13 @@ use crate::recipe::build_recipe::{
};
use crate::recipe::read_recipe_file_content::RecipeFile;
use crate::recipe::{RecipeParameterInputType, RecipeParameterRequirement};
use std::path::PathBuf;
use tempfile::TempDir;
#[allow(clippy::type_complexity)]
const NO_USER_PROMPT: Option<fn(&str, &str) -> Result<String, anyhow::Error>> = None;
fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, RecipeFile) {
fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, String, PathBuf) {
let recipe_content = format!(
r#"{{
"version": "1.0.0",
@@ -22,14 +23,10 @@ fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, RecipeFile)
let recipe_path = temp_dir.path().join("test_recipe.json");
std::fs::write(&recipe_path, recipe_content).unwrap();
let recipe_dir = temp_dir.path().to_path_buf();
let recipe_content = std::fs::read_to_string(&recipe_path).unwrap();
let recipe_file = RecipeFile {
content: std::fs::read_to_string(&recipe_path).unwrap(),
parent_dir: temp_dir.path().to_path_buf(),
file_path: recipe_path,
};
(temp_dir, recipe_file)
(temp_dir, recipe_content, recipe_dir)
}
fn setup_test_file(temp_dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf {
@@ -101,10 +98,11 @@ fn test_build_recipe_from_template_success() {
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let params = vec![("my_name".to_string(), "value".to_string())];
let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap();
let recipe =
build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
@@ -134,10 +132,11 @@ fn test_build_recipe_from_template_success_variable_in_prompt() {
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let params = vec![("my_name".to_string(), "value".to_string())];
let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap();
let recipe =
build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
@@ -165,9 +164,10 @@ fn test_build_recipe_from_template_wrong_parameters_in_recipe_file() {
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let build_recipe_result = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT);
let build_recipe_result =
build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT);
assert!(build_recipe_result.is_err());
let err = build_recipe_result.unwrap_err();
println!("{}", err);
@@ -203,10 +203,11 @@ fn test_build_recipe_from_template_with_default_values_in_recipe_file() {
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let params = vec![("param_without_default".to_string(), "value1".to_string())];
let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap();
let recipe =
build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT).unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
@@ -229,9 +230,11 @@ fn test_build_recipe_from_template_optional_parameters_with_empty_default_values
"default": ""
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let recipe = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT).unwrap();
let recipe =
build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT)
.unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions.unwrap(), "Test instructions with ");
@@ -249,9 +252,10 @@ fn test_build_recipe_from_template_optional_parameters_without_default_values_in
"description": "A test parameter"
}
]"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let build_recipe_result = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT);
let build_recipe_result =
build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT);
assert!(build_recipe_result.is_err());
let err = build_recipe_result.unwrap_err();
println!("{}", err);
@@ -276,9 +280,10 @@ fn test_build_recipe_from_template_wrong_input_type_in_recipe_file() {
}
]"#;
let params = vec![("param".to_string(), "value".to_string())];
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let build_recipe_result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT);
let build_recipe_result =
build_recipe_from_template(recipe_content, &recipe_dir, params, NO_USER_PROMPT);
assert!(build_recipe_result.is_err());
let err = build_recipe_result.unwrap_err();
match err {
@@ -296,9 +301,11 @@ fn test_build_recipe_from_template_success_without_parameters() {
let instructions_and_parameters = r#"
"instructions": "Test instructions"
"#;
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let recipe = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT).unwrap();
let recipe =
build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT)
.unwrap();
assert_eq!(recipe.instructions.unwrap(), "Test instructions");
assert!(recipe.parameters.is_none());
}
@@ -306,9 +313,10 @@ fn test_build_recipe_from_template_success_without_parameters() {
#[test]
fn test_build_recipe_from_template_missing_prompt_and_instructions() {
let instructions_and_parameters = "";
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let (_temp_dir, recipe_content, recipe_dir) = setup_recipe_file(instructions_and_parameters);
let build_recipe_result = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT);
let build_recipe_result =
build_recipe_from_template(recipe_content, &recipe_dir, Vec::new(), NO_USER_PROMPT);
assert!(build_recipe_result.is_err());
let err = build_recipe_result.unwrap_err();
println!("{}", err);
@@ -366,8 +374,13 @@ fn test_template_inheritance() {
("is_enabled".to_string(), "true".to_string()),
];
let parent_recipe =
build_recipe_from_template(parent_recipe_file, params.clone(), NO_USER_PROMPT).unwrap();
let parent_recipe = build_recipe_from_template(
parent_recipe_file.content,
&parent_recipe_file.parent_dir,
params.clone(),
NO_USER_PROMPT,
)
.unwrap();
assert_eq!(parent_recipe.description, "Parent recipe");
assert_eq!(
parent_recipe.prompt.unwrap(),
@@ -380,8 +393,13 @@ fn test_template_inheritance() {
"is_enabled"
);
let child_recipe =
build_recipe_from_template(child_recipe_file, params, NO_USER_PROMPT).unwrap();
let child_recipe = build_recipe_from_template(
child_recipe_file.content,
&child_recipe_file.parent_dir,
params,
NO_USER_PROMPT,
)
.unwrap();
assert_eq!(child_recipe.title, "Parent");
assert_eq!(child_recipe.description, "Parent recipe");
assert_eq!(
@@ -467,7 +485,13 @@ instructions: Child instructions
file_path: main_recipe_path,
};
let recipe = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT).unwrap();
let recipe = build_recipe_from_template(
recipe_file.content,
&recipe_file.parent_dir,
Vec::new(),
NO_USER_PROMPT,
)
.unwrap();
assert_eq!(recipe.title, "Main Recipe");
assert!(recipe.sub_recipes.is_some());
@@ -505,7 +529,12 @@ parameters:
"FILE_PARAM".to_string(),
test_file_path.to_string_lossy().to_string(),
)];
let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT);
let result = build_recipe_from_template(
recipe_file.content,
&recipe_file.parent_dir,
params,
NO_USER_PROMPT,
);
assert!(result.is_ok());
let recipe = result.unwrap();
@@ -530,7 +559,12 @@ parameters:
"FILE_PARAM".to_string(),
"/nonexistent/path/file.txt".to_string(),
)];
let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT);
let result = build_recipe_from_template(
recipe_file.content,
&recipe_file.parent_dir,
params,
NO_USER_PROMPT,
);
assert!(result.is_err());
if let Err(RecipeError::TemplateRendering { source }) = result {
@@ -553,7 +587,12 @@ parameters:
let (_temp_dir, recipe_file) = setup_yaml_recipe_file(instructions_and_parameters);
let params = vec![];
let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT);
let result = build_recipe_from_template(
recipe_file.content,
&recipe_file.parent_dir,
params,
NO_USER_PROMPT,
);
assert!(result.is_err());
if let Err(RecipeError::TemplateRendering { source }) = result {
+18 -36
View File
@@ -150,6 +150,11 @@ fn get_env_with_template_variables(
Ok((env, template_variables))
}
fn uses_template_inheritance(content: &str) -> bool {
let re = Regex::new(r"\{%-?\s*(extends|include)").unwrap();
re.is_match(content)
}
pub fn parse_recipe_content(
content: &str,
recipe_dir: Option<String>,
@@ -163,46 +168,23 @@ pub fn parse_recipe_content(
UndefinedBehavior::Lenient,
)?;
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
let rendered_content = template
.render(())
.map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?;
let recipe = Recipe::from_content(&rendered_content)?;
// Detect if template uses inheritance or includes
let recipe_content = if uses_template_inheritance(&preprocessed_content) {
// Must render to resolve inheritance
template
.render(())
.map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?
} else {
// Preserve conditionals and variables as-is
preprocessed_content
};
let recipe = Recipe::from_content(&recipe_content)?;
// return recipe (without loading any variables) and the variable names that are in the recipe
Ok((recipe, template_variables))
}
// render the recipe for validation, deeplink and explain, etc.
pub fn render_recipe_for_preview(
content: &str,
recipe_dir: Option<String>,
params: &HashMap<String, String>,
) -> Result<Recipe> {
// Pre-process template variables to handle invalid variable names
let preprocessed_content = preprocess_template_variables(content)?;
let (env, template_variables) = get_env_with_template_variables(
&preprocessed_content,
recipe_dir,
UndefinedBehavior::Lenient,
)?;
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
// if the variables are not provided, the template will be rendered with the variables, otherwise it will keep the variables as is
let mut ctx = preserve_vars(&template_variables).clone();
ctx.extend(params.clone());
let rendered_content = template
.render(ctx)
.map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?;
Recipe::from_content(&rendered_content)
}
fn preserve_vars(variables: &HashSet<String>) -> HashMap<String, String> {
let mut context = HashMap::<String, String>::new();
for template_var in variables {
context.insert(template_var.clone(), format!("{{{{ {} }}}}", template_var));
}
context
}
#[cfg(test)]
mod tests {
mod render_content_with_params_tests {
+54 -10
View File
@@ -1,22 +1,22 @@
use crate::recipe::read_recipe_file_content::RecipeFile;
use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_for_preview};
use crate::recipe::template_recipe::parse_recipe_content;
use crate::recipe::{
Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement,
BUILT_IN_RECIPE_DIR_PARAM,
};
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
pub fn validate_recipe_parameters(
pub fn parse_and_validate_parameters(
recipe_file_content: &str,
recipe_dir_str: Option<String>,
) -> Result<Option<Vec<RecipeParameter>>> {
) -> Result<Recipe> {
let (recipe_template, template_variables) =
parse_recipe_content(recipe_file_content, recipe_dir_str)?;
let recipe_parameters = recipe_template.parameters;
validate_optional_parameters(&recipe_parameters)?;
validate_parameters_in_template(&recipe_parameters, &template_variables)?;
Ok(recipe_parameters)
let recipe_parameters = &recipe_template.parameters;
validate_optional_parameters(recipe_parameters)?;
validate_parameters_in_template(recipe_parameters, &template_variables)?;
Ok(recipe_template)
}
fn validate_json_schema(schema: &serde_json::Value) -> Result<()> {
@@ -40,8 +40,8 @@ pub fn validate_recipe_template_from_content(
recipe_content: &str,
recipe_dir: Option<String>,
) -> Result<Recipe> {
validate_recipe_parameters(recipe_content, recipe_dir.clone())?;
let recipe = render_recipe_for_preview(recipe_content, recipe_dir, &HashMap::new())?;
parse_and_validate_parameters(recipe_content, recipe_dir.clone())?;
let (recipe, _) = parse_recipe_content(recipe_content, recipe_dir)?;
validate_prompt_or_instructions(&recipe)?;
if let Some(response) = &recipe.response {
@@ -154,3 +154,47 @@ fn validate_optional_parameters(parameters: &Option<Vec<RecipeParameter>>) -> Re
Err(anyhow::anyhow!("Optional parameters missing default values in the recipe: {}. Please provide defaults.", optional_params_without_default_values.join(", ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_recipe_template_from_content_success() {
let recipe_content = r#"
version: 1.0.0
title: Test Recipe
description: A test recipe for validation
instructions: Test instructions with {{ user_role }}
prompt: |
{% if user_role in ["Director, Account Management", "Senior Director, Account Management"] %}
- Focus on strategic planning and organizational performance
{% else %}
- Provide foundational account management guidance
{% endif %}
parameters:
- key: user_role
input_type: string
requirement: required
description: A test parameter
"#;
let result = validate_recipe_template_from_content(recipe_content, None);
if let Err(e) = &result {
eprintln!("Validation error: {}", e);
eprintln!("Error chain:");
let mut source = e.source();
while let Some(err) = source {
eprintln!(" Caused by: {}", err);
source = err.source();
}
}
assert!(result.is_ok(), "Validation failed: {:?}", result.err());
let recipe = result.unwrap();
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe for validation");
assert!(recipe.instructions.is_some());
println!("Recipe: {:?}", recipe.prompt);
}
}