//! Tool definitions for file editing via LLM. use jaq_interpret::FilterT; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::db::models::{BodyElement, ChartType}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tool { pub name: String, pub description: String, pub parameters: serde_json::Value, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCall { pub id: String, pub name: String, pub arguments: serde_json::Value, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ToolResult { pub success: bool, pub message: String, } /// Available tools for file editing pub static AVAILABLE_TOOLS: once_cell::sync::Lazy> = once_cell::sync::Lazy::new(|| { vec![ Tool { name: "add_heading".to_string(), description: "Add a heading element to the file body".to_string(), parameters: json!({ "type": "object", "properties": { "level": { "type": "integer", "description": "Heading level (1-6)", "minimum": 1, "maximum": 6 }, "text": { "type": "string", "description": "The heading text" }, "position": { "type": "integer", "description": "Optional position to insert at (0-indexed). If not specified, appends to end." } }, "required": ["level", "text"] }), }, Tool { name: "add_paragraph".to_string(), description: "Add a paragraph element to the file body".to_string(), parameters: json!({ "type": "object", "properties": { "text": { "type": "string", "description": "The paragraph text" }, "position": { "type": "integer", "description": "Optional position to insert at (0-indexed). If not specified, appends to end." } }, "required": ["text"] }), }, Tool { name: "add_chart".to_string(), description: "Add a chart visualization to the file body. Supports line, bar, pie, and area charts.".to_string(), parameters: json!({ "type": "object", "properties": { "chart_type": { "type": "string", "enum": ["line", "bar", "pie", "area"], "description": "Type of chart to create" }, "title": { "type": "string", "description": "Optional chart title" }, "data": { "type": "array", "description": "Array of data points. Each point should have a 'name' field and one or more numeric value fields.", "items": { "type": "object" } }, "config": { "type": "object", "description": "Optional chart configuration (colors, axes, etc.)" }, "position": { "type": "integer", "description": "Optional position to insert at (0-indexed). If not specified, appends to end." } }, "required": ["chart_type", "data"] }), }, Tool { name: "remove_element".to_string(), description: "Remove an element from the file body by index".to_string(), parameters: json!({ "type": "object", "properties": { "index": { "type": "integer", "description": "Index of element to remove (0-indexed)" } }, "required": ["index"] }), }, Tool { name: "update_element".to_string(), description: "Update an existing element in the file body. IMPORTANT: You must provide ALL required fields. For heading: type, level (1-6), text. For paragraph: type, text. For chart: type, chartType (line/bar/pie/area), data (array of objects).".to_string(), parameters: json!({ "type": "object", "properties": { "index": { "type": "integer", "description": "Index of element to update (0-indexed)" }, "element_type": { "type": "string", "enum": ["heading", "paragraph", "chart"], "description": "Type of element" }, "text": { "type": "string", "description": "Text content (required for heading and paragraph)" }, "level": { "type": "integer", "description": "Heading level 1-6 (required for heading)" }, "chartType": { "type": "string", "enum": ["line", "bar", "pie", "area"], "description": "Chart type (required for chart)" }, "data": { "type": "array", "description": "Chart data array (required for chart)", "items": { "type": "object" } }, "title": { "type": "string", "description": "Chart title (optional for chart)" } }, "required": ["index", "element_type"] }), }, Tool { name: "reorder_elements".to_string(), description: "Move an element from one position to another".to_string(), parameters: json!({ "type": "object", "properties": { "from_index": { "type": "integer", "description": "Current index of the element" }, "to_index": { "type": "integer", "description": "New index for the element" } }, "required": ["from_index", "to_index"] }), }, Tool { name: "set_summary".to_string(), description: "Set the file summary text".to_string(), parameters: json!({ "type": "object", "properties": { "summary": { "type": "string", "description": "The summary text" } }, "required": ["summary"] }), }, Tool { name: "parse_csv".to_string(), description: "Parse CSV data into JSON format suitable for charts".to_string(), parameters: json!({ "type": "object", "properties": { "csv": { "type": "string", "description": "CSV data string with header row" } }, "required": ["csv"] }), }, Tool { name: "clear_body".to_string(), description: "Clear all elements from the file body".to_string(), parameters: json!({ "type": "object", "properties": {} }), }, Tool { name: "jq".to_string(), description: "Transform JSON data using jq expressions. Useful for filtering, mapping, grouping, and aggregating data before creating charts.".to_string(), parameters: json!({ "type": "object", "properties": { "input": { "description": "The JSON data to transform (can be an array or object)" }, "filter": { "type": "string", "description": "The jq filter expression. Examples: '.[] | select(.value > 10)', 'group_by(.category) | map({name: .[0].category, count: length})', '[.[] | {name: .label, value: .amount}]'" } }, "required": ["input", "filter"] }), }, ] }); /// Result of executing a tool call with modified file state #[derive(Debug)] pub struct ToolExecutionResult { pub result: ToolResult, pub new_body: Option>, pub new_summary: Option, pub parsed_data: Option, } /// Execute a tool call and return the result along with any state changes pub fn execute_tool_call( call: &ToolCall, current_body: &[BodyElement], current_summary: Option<&str>, ) -> ToolExecutionResult { match call.name.as_str() { "add_heading" => execute_add_heading(call, current_body), "add_paragraph" => execute_add_paragraph(call, current_body), "add_chart" => execute_add_chart(call, current_body), "remove_element" => execute_remove_element(call, current_body), "update_element" => execute_update_element(call, current_body), "reorder_elements" => execute_reorder_elements(call, current_body), "set_summary" => execute_set_summary(call, current_summary), "parse_csv" => execute_parse_csv(call), "clear_body" => execute_clear_body(), "jq" => execute_jq(call), _ => ToolExecutionResult { result: ToolResult { success: false, message: format!("Unknown tool: {}", call.name), }, new_body: None, new_summary: None, parsed_data: None, }, } } fn execute_add_heading(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let level = call.arguments.get("level").and_then(|v| v.as_u64()).unwrap_or(1) as u8; let text = call .arguments .get("text") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let position = call.arguments.get("position").and_then(|v| v.as_u64()); let element = BodyElement::Heading { level, text: text.clone() }; let mut new_body = current_body.to_vec(); if let Some(pos) = position { let pos = pos as usize; if pos <= new_body.len() { new_body.insert(pos, element); } else { new_body.push(element); } } else { new_body.push(element); } ToolExecutionResult { result: ToolResult { success: true, message: format!("Added heading: {}", text), }, new_body: Some(new_body), new_summary: None, parsed_data: None, } } fn execute_add_paragraph(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let text = call .arguments .get("text") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let position = call.arguments.get("position").and_then(|v| v.as_u64()); let element = BodyElement::Paragraph { text: text.clone() }; let mut new_body = current_body.to_vec(); if let Some(pos) = position { let pos = pos as usize; if pos <= new_body.len() { new_body.insert(pos, element); } else { new_body.push(element); } } else { new_body.push(element); } let preview = if text.len() > 50 { format!("{}...", &text[..50]) } else { text }; ToolExecutionResult { result: ToolResult { success: true, message: format!("Added paragraph: {}", preview), }, new_body: Some(new_body), new_summary: None, parsed_data: None, } } fn execute_add_chart(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let chart_type_str = call .arguments .get("chart_type") .and_then(|v| v.as_str()) .unwrap_or("bar"); let chart_type = match chart_type_str { "line" => ChartType::Line, "bar" => ChartType::Bar, "pie" => ChartType::Pie, "area" => ChartType::Area, _ => ChartType::Bar, }; let title = call .arguments .get("title") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let data = call .arguments .get("data") .cloned() .unwrap_or(json!([])); let config = call.arguments.get("config").cloned(); let position = call.arguments.get("position").and_then(|v| v.as_u64()); let element = BodyElement::Chart { chart_type, title: title.clone(), data, config, }; let mut new_body = current_body.to_vec(); if let Some(pos) = position { let pos = pos as usize; if pos <= new_body.len() { new_body.insert(pos, element); } else { new_body.push(element); } } else { new_body.push(element); } ToolExecutionResult { result: ToolResult { success: true, message: format!( "Added {} chart{}", chart_type_str, title.map(|t| format!(": {}", t)).unwrap_or_default() ), }, new_body: Some(new_body), new_summary: None, parsed_data: None, } } fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let index = call.arguments.get("index").and_then(|v| v.as_u64()); let Some(index) = index else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing index parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, }; }; let index = index as usize; if index >= current_body.len() { return ToolExecutionResult { result: ToolResult { success: false, message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()), }, new_body: None, new_summary: None, parsed_data: None, }; } let mut new_body = current_body.to_vec(); new_body.remove(index); ToolExecutionResult { result: ToolResult { success: true, message: format!("Removed element at index {}", index), }, new_body: Some(new_body), new_summary: None, parsed_data: None, } } fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let index = call.arguments.get("index").and_then(|v| v.as_u64()); let element_type = call.arguments.get("element_type").and_then(|v| v.as_str()); let Some(index) = index else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing index parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, }; }; let Some(element_type) = element_type else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing element_type parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, }; }; let index = index as usize; if index >= current_body.len() { return ToolExecutionResult { result: ToolResult { success: false, message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()), }, new_body: None, new_summary: None, parsed_data: None, }; } // Build the element based on type let new_element = match element_type { "heading" => { let level = call.arguments.get("level").and_then(|v| v.as_u64()).unwrap_or(1) as u8; let text = call.arguments.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(); BodyElement::Heading { level, text } } "paragraph" => { let text = call.arguments.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(); BodyElement::Paragraph { text } } "chart" => { let chart_type_str = call.arguments.get("chartType").and_then(|v| v.as_str()).unwrap_or("bar"); let chart_type = match chart_type_str { "line" => ChartType::Line, "bar" => ChartType::Bar, "pie" => ChartType::Pie, "area" => ChartType::Area, _ => ChartType::Bar, }; let title = call.arguments.get("title").and_then(|v| v.as_str()).map(|s| s.to_string()); let data = call.arguments.get("data").cloned().unwrap_or(json!([])); let config = call.arguments.get("config").cloned(); BodyElement::Chart { chart_type, title, data, config } } _ => { return ToolExecutionResult { result: ToolResult { success: false, message: format!("Unknown element_type: {}. Must be heading, paragraph, or chart.", element_type), }, new_body: None, new_summary: None, parsed_data: None, }; } }; let mut new_body = current_body.to_vec(); new_body[index] = new_element; ToolExecutionResult { result: ToolResult { success: true, message: format!("Updated element at index {} to {}", index, element_type), }, new_body: Some(new_body), new_summary: None, parsed_data: None, } } fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let from_index = call.arguments.get("from_index").and_then(|v| v.as_u64()); let to_index = call.arguments.get("to_index").and_then(|v| v.as_u64()); let (Some(from), Some(to)) = (from_index, to_index) else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing from_index or to_index parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, }; }; let from = from as usize; let to = to as usize; if from >= current_body.len() || to >= current_body.len() { return ToolExecutionResult { result: ToolResult { success: false, message: format!( "Index out of bounds: from={}, to={}, body has {} elements", from, to, current_body.len() ), }, new_body: None, new_summary: None, parsed_data: None, }; } let mut new_body = current_body.to_vec(); let element = new_body.remove(from); new_body.insert(to, element); ToolExecutionResult { result: ToolResult { success: true, message: format!("Moved element from index {} to {}", from, to), }, new_body: Some(new_body), new_summary: None, parsed_data: None, } } fn execute_set_summary(call: &ToolCall, _current_summary: Option<&str>) -> ToolExecutionResult { let summary = call .arguments .get("summary") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); ToolExecutionResult { result: ToolResult { success: true, message: "Summary updated".to_string(), }, new_body: None, new_summary: Some(summary), parsed_data: None, } } fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult { let csv = call .arguments .get("csv") .and_then(|v| v.as_str()) .unwrap_or(""); let lines: Vec<&str> = csv.lines().collect(); if lines.is_empty() { return ToolExecutionResult { result: ToolResult { success: false, message: "Empty CSV data".to_string(), }, new_body: None, new_summary: None, parsed_data: None, }; } let headers: Vec<&str> = lines[0].split(',').map(|s| s.trim()).collect(); let mut data: Vec = Vec::new(); for line in lines.iter().skip(1) { if line.trim().is_empty() { continue; } let values: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); let mut row = serde_json::Map::new(); for (i, header) in headers.iter().enumerate() { if let Some(value) = values.get(i) { // Try to parse as number, otherwise use string if let Ok(num) = value.parse::() { row.insert(header.to_string(), json!(num)); } else { row.insert(header.to_string(), json!(value)); } } } data.push(serde_json::Value::Object(row)); } ToolExecutionResult { result: ToolResult { success: true, message: format!("Parsed {} rows from CSV", data.len()), }, new_body: None, new_summary: None, parsed_data: Some(json!(data)), } } fn execute_clear_body() -> ToolExecutionResult { ToolExecutionResult { result: ToolResult { success: true, message: "Cleared all body elements".to_string(), }, new_body: Some(vec![]), new_summary: None, parsed_data: None, } } fn execute_jq(call: &ToolCall) -> ToolExecutionResult { let input = match call.arguments.get("input") { Some(v) => v.clone(), None => { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing input parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, }; } }; let filter = match call.arguments.get("filter").and_then(|v| v.as_str()) { Some(f) => f, None => { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing filter parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, }; } }; // Parse the jq filter let mut defs = jaq_interpret::ParseCtx::new(Vec::new()); defs.insert_natives(jaq_core::core()); defs.insert_defs(jaq_std::std()); let (parsed_filter, errs) = jaq_parse::parse(filter, jaq_parse::main()); if !errs.is_empty() { return ToolExecutionResult { result: ToolResult { success: false, message: format!("Invalid jq filter: {:?}", errs), }, new_body: None, new_summary: None, parsed_data: None, }; } let Some(parsed_filter) = parsed_filter else { return ToolExecutionResult { result: ToolResult { success: false, message: "Failed to parse jq filter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, }; }; // Compile the filter let compiled = defs.compile(parsed_filter); if !defs.errs.is_empty() { return ToolExecutionResult { result: ToolResult { success: false, message: format!("Failed to compile jq filter ({} errors)", defs.errs.len()), }, new_body: None, new_summary: None, parsed_data: None, }; } // Convert serde_json::Value to jaq Value let jaq_input = json_to_jaq(&input); // Execute the filter let inputs = jaq_interpret::RcIter::new(std::iter::empty()); let mut results: Vec = Vec::new(); for output in compiled.run((jaq_interpret::Ctx::new([], &inputs), jaq_input)) { match output { Ok(val) => { results.push(jaq_to_json(&val)); } Err(e) => { return ToolExecutionResult { result: ToolResult { success: false, message: format!("jq execution error: {:?}", e), }, new_body: None, new_summary: None, parsed_data: None, }; } } } // Return single value or array based on results let output = if results.len() == 1 { results.into_iter().next().unwrap() } else { json!(results) }; let preview = { let s = output.to_string(); if s.len() > 100 { format!("{}...", &s[..100]) } else { s } }; ToolExecutionResult { result: ToolResult { success: true, message: format!("jq transform complete: {}", preview), }, new_body: None, new_summary: None, parsed_data: Some(output), } } /// Convert serde_json::Value to jaq_interpret::Val fn json_to_jaq(value: &serde_json::Value) -> jaq_interpret::Val { match value { serde_json::Value::Null => jaq_interpret::Val::Null, serde_json::Value::Bool(b) => jaq_interpret::Val::Bool(*b), serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { jaq_interpret::Val::Int(i as isize) } else if let Some(f) = n.as_f64() { jaq_interpret::Val::Float(f) } else { jaq_interpret::Val::Null } } serde_json::Value::String(s) => jaq_interpret::Val::Str(s.clone().into()), serde_json::Value::Array(arr) => { jaq_interpret::Val::Arr(std::rc::Rc::new(arr.iter().map(json_to_jaq).collect())) } serde_json::Value::Object(obj) => { let mut map: indexmap::IndexMap, jaq_interpret::Val, ahash::RandomState> = indexmap::IndexMap::with_hasher(ahash::RandomState::new()); for (k, v) in obj { map.insert(std::rc::Rc::new(k.clone()), json_to_jaq(v)); } jaq_interpret::Val::Obj(std::rc::Rc::new(map)) } } } /// Convert jaq_interpret::Val to serde_json::Value fn jaq_to_json(value: &jaq_interpret::Val) -> serde_json::Value { match value { jaq_interpret::Val::Null => serde_json::Value::Null, jaq_interpret::Val::Bool(b) => json!(*b), jaq_interpret::Val::Int(i) => json!(*i), jaq_interpret::Val::Float(f) => json!(*f), jaq_interpret::Val::Num(n) => { // Try to parse the number string if let Ok(i) = n.parse::() { json!(i) } else if let Ok(f) = n.parse::() { json!(f) } else { json!(n.as_ref()) } } jaq_interpret::Val::Str(s) => json!(s.as_ref()), jaq_interpret::Val::Arr(arr) => { json!(arr.iter().map(jaq_to_json).collect::>()) } jaq_interpret::Val::Obj(obj) => { let mut map = serde_json::Map::new(); for (k, v) in obj.iter() { map.insert((**k).clone(), jaq_to_json(v)); } serde_json::Value::Object(map) } } }