diff --git a/api/libs/agents/src/agents/modes/analysis.rs b/api/libs/agents/src/agents/modes/analysis.rs index 2709faed0..1fdd28484 100644 --- a/api/libs/agents/src/agents/modes/analysis.rs +++ b/api/libs/agents/src/agents/modes/analysis.rs @@ -205,6 +205,7 @@ You can create, update, or modify the following assets, which are automatically - **Review and Update**: After creation, metrics can be reviewed and updated individually or in bulk as needed. - **Use in Dashboards**: Metrics can be saved to dashboards for further use. - **Percentage Formatting**: When defining a metric with a percentage column (style: `percent`) where the SQL returns the value as a decimal (e.g., 0.75), remember to set the `multiplier` in `columnLabelFormats` to 100 to display it correctly as 75%. If the value is already represented as a percentage (e.g., 75), the multiplier should be 1 (or omitted as it defaults to 1). + - **Date Grouping**: For metrics visualizing date columns on the X-axis (e.g., line or combo charts), remember to set the `xAxisTimeInterval` field within the `xAxisConfig` section of `chartConfig` to control how dates are grouped (e.g., `day`, `week`, `month`). This is crucial for meaningful time-series visualizations. - **Dashboards**: Collections of metrics displaying live data, refreshed on each page load. Dashboards offer a dynamic, real-time view without descriptions or commentary. diff --git a/api/libs/agents/src/tools/categories/file_tools/common.rs b/api/libs/agents/src/tools/categories/file_tools/common.rs index a6b110d47..9c26b1db6 100644 --- a/api/libs/agents/src/tools/categories/file_tools/common.rs +++ b/api/libs/agents/src/tools/categories/file_tools/common.rs @@ -244,7 +244,7 @@ required: - chartConfig definitions: - # BASE CHART CONFIG (common parts required by ALL chart types) + # BASE CHART CONFIG (common parts used by ALL chart types) base_chart_config: type: object properties: @@ -275,6 +275,10 @@ definitions: type: boolean gridLines: type: boolean + showLegendHeadline: + oneOf: + - type: boolean + - type: string goalLines: type: array items: @@ -283,10 +287,105 @@ definitions: type: array items: $ref: #/definitions/trendline + disableTooltip: + type: boolean + # Axis Configurations + xAxisConfig: + description: Optional X-axis configuration. Primarily used to set the `xAxisTimeInterval` for date axes (day, week, month, etc.). Other properties control label visibility, title, rotation, and zoom. + $ref: '#/definitions/x_axis_config' + yAxisConfig: + description: Optional Y-axis configuration. Primarily used to set the `yAxisShowAxisLabel` and `yAxisShowAxisTitle` properties. Other properties control label visibility, title, rotation, and zoom. + $ref: '#/definitions/y_axis_config' + y2AxisConfig: + description: Optional secondary Y-axis configuration. Used for combo charts. + $ref: '#/definitions/y2_axis_config' + categoryAxisStyleConfig: + description: Optional style configuration for the category axis (color/grouping). + $ref: '#/definitions/category_axis_style_config' required: - selectedChartType - columnLabelFormats + # AXIS CONFIGURATIONS + x_axis_config: + type: object + properties: + xAxisTimeInterval: + type: string + enum: [day, week, month, quarter, year, 'null'] + description: Time interval for X-axis (combo/line charts). Default: null. + xAxisShowAxisLabel: + type: boolean + description: Show X-axis labels. Default: true. + xAxisShowAxisTitle: + type: boolean + description: Show X-axis title. Default: true. + xAxisAxisTitle: + type: [string, 'null'] + description: X-axis title. Default: null (auto-generates from column names). + xAxisLabelRotation: + type: string # Representing numbers or 'auto' + enum: ["0", "45", "90", auto] + description: Label rotation. Default: auto. + xAxisDataZoom: + type: boolean + description: Enable data zoom on X-axis. Default: false (User only). + additionalProperties: false + required: + - xAxisTimeInterval + + y_axis_config: + type: object + properties: + yAxisShowAxisLabel: + type: boolean + description: Show Y-axis labels. Default: true. + yAxisShowAxisTitle: + type: boolean + description: Show Y-axis title. Default: true. + yAxisAxisTitle: + type: [string, 'null'] + description: Y-axis title. Default: null (uses first plotted column name). + yAxisStartAxisAtZero: + type: [boolean, 'null'] + description: Start Y-axis at zero. Default: true. + yAxisScaleType: + type: string + enum: [log, linear] + description: Scale type for Y-axis. Default: linear. + additionalProperties: false + + y2_axis_config: + type: object + description: Secondary Y-axis configuration (for combo charts). + properties: + y2AxisShowAxisLabel: + type: boolean + description: Show Y2-axis labels. Default: true. + y2AxisShowAxisTitle: + type: boolean + description: Show Y2-axis title. Default: true. + y2AxisAxisTitle: + type: [string, 'null'] + description: Y2-axis title. Default: null (uses first plotted column name). + y2AxisStartAxisAtZero: + type: [boolean, 'null'] + description: Start Y2-axis at zero. Default: true. + y2AxisScaleType: + type: string + enum: [log, linear] + description: Scale type for Y2-axis. Default: linear. + additionalProperties: false + + category_axis_style_config: + type: object + description: Style configuration for the category axis (color/grouping). + properties: + categoryAxisTitle: + type: [string, 'null'] + description: Title for the category axis. + additionalProperties: false + # COLUMN FORMATTING columnLabelFormat: type: object @@ -305,7 +404,7 @@ definitions: # - If the value comes directly from a database column, use multiplier: 1 # - If the value is calculated in your SQL query and not already multiplied by 100, use multiplier: 100 - number - - date + - date # Note: For date columns, consider setting xAxisTimeInterval in xAxisConfig to control date grouping (day, week, month, quarter, year) - string multiplier: type: number diff --git a/api/libs/database/src/models.rs b/api/libs/database/src/models.rs index 92ca78537..1aa358ca4 100644 --- a/api/libs/database/src/models.rs +++ b/api/libs/database/src/models.rs @@ -59,7 +59,7 @@ pub struct Message { pub feedback: Option, } -#[derive(Queryable, Insertable, Debug)] +#[derive(Queryable, Insertable, Debug, Clone)] #[diesel(table_name = messages_to_files)] pub struct MessageToFile { pub id: Uuid, diff --git a/api/libs/database/src/types/metric_yml.rs b/api/libs/database/src/types/metric_yml.rs index 6cb82968e..d3110d3df 100644 --- a/api/libs/database/src/types/metric_yml.rs +++ b/api/libs/database/src/types/metric_yml.rs @@ -205,15 +205,19 @@ pub struct BaseChartConfig { #[serde(skip_serializing_if = "Option::is_none")] #[serde(alias = "disable_tooltip")] pub disable_tooltip: Option, - // Updated Axis Configs using defined structs - #[serde(flatten, default)] // Flatten includes fields directly, default handles Option - pub y_axis_config: YAxisConfig, - #[serde(flatten, default)] - pub x_axis_config: XAxisConfig, - #[serde(flatten, default)] - pub category_axis_style_config: CategoryAxisStyleConfig, - #[serde(flatten, default)] - pub y2_axis_config: Y2AxisConfig, + // Updated Axis Configs using defined structs (now optional) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(alias = "y_axis_config")] + pub y_axis_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(alias = "x_axis_config")] + pub x_axis_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(alias = "category_axis_style_config")] + pub category_axis_style_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(alias = "y2_axis_config")] + pub y2_axis_config: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/api/libs/handlers/src/chats/restore_chat_handler.rs b/api/libs/handlers/src/chats/restore_chat_handler.rs index 9f337cd34..0b3e4c44b 100644 --- a/api/libs/handlers/src/chats/restore_chat_handler.rs +++ b/api/libs/handlers/src/chats/restore_chat_handler.rs @@ -8,6 +8,7 @@ use database::{ }; use diesel::{insert_into, update, ExpressionMethods, QueryDsl}; use diesel_async::RunQueryDsl; +use futures::future::try_join_all; use middleware::AuthenticatedUser; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -20,7 +21,7 @@ use crate::dashboards::{update_dashboard_handler, DashboardUpdateRequest}; use crate::metrics::{update_metric_handler, UpdateMetricRequest}; /// Request structure for restoring an asset (metric or dashboard) version in a chat -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct ChatRestoreRequest { /// ID of the asset to restore pub asset_id: Uuid, @@ -41,84 +42,102 @@ pub struct ChatRestoreRequest { /// * `Result` - The updated chat with new messages documenting the restoration /// /// # Process -/// 1. Restores the specified asset version using the appropriate handler -/// 2. Creates a text message in the chat documenting the restoration -/// 3. Creates a file message linking to the restored asset -/// 4. Updates the chat record with the latest file info -/// 5. Returns the updated chat with all messages +/// 1. Concurrently: +/// a. Restores the specified asset version using the appropriate handler +/// b. Fetches the most recent message from the chat (to copy raw_llm_messages) +/// 2. Waits for restoration and fetch to complete. +/// 3. Constructs new message details (text, file link, raw_llm_messages). +/// 4. Concurrently: +/// a. Inserts the new message documenting the restoration +/// b. Inserts the message-to-file association +/// c. Updates the chat record with the latest file info +/// 5. Waits for insertions and update to complete. +/// 6. Returns the updated chat with all messages pub async fn restore_chat_handler( chat_id: &Uuid, user: &AuthenticatedUser, request: ChatRestoreRequest, ) -> Result { - let mut conn = get_pg_pool().get().await?; + // Clone variables needed for concurrent tasks + let user_clone1 = user.clone(); + let request_clone1 = request.clone(); + let chat_id_clone1 = *chat_id; - // Step 1: Restore the asset using the appropriate handler - let (file_type, file_name, file_id, version_number) = match request.asset_type { - AssetType::MetricFile => { - // Create a metric update request with only the restore_to_version parameter - let metric_request = UpdateMetricRequest { - restore_to_version: Some(request.version_number), - ..Default::default() - }; + // Task 1: Restore Asset + let restore_task = tokio::spawn(async move { + let (file_type, file_name, file_id, version_number) = match request_clone1.asset_type { + AssetType::MetricFile => { + let metric_request = UpdateMetricRequest { + restore_to_version: Some(request_clone1.version_number), + ..Default::default() + }; + let updated_metric = + update_metric_handler(&request_clone1.asset_id, &user_clone1, metric_request) + .await?; + ( + "metric".to_string(), + updated_metric.name, + updated_metric.id, + updated_metric.versions.len() as i32, + ) + } + AssetType::DashboardFile => { + let dashboard_request = DashboardUpdateRequest { + restore_to_version: Some(request_clone1.version_number), + ..Default::default() + }; + let updated_dashboard = update_dashboard_handler( + request_clone1.asset_id, + dashboard_request, + &user_clone1, + ) + .await?; + ( + "dashboard".to_string(), + updated_dashboard.dashboard.name, + updated_dashboard.dashboard.id, + updated_dashboard.dashboard.version_number, + ) + } + _ => { + return Err(anyhow!( + "Unsupported asset type for restoration: {:?}", + request_clone1.asset_type + )) + } + }; + // Explicitly type the Ok variant for the compiler + Ok::<_, anyhow::Error>((file_type, file_name, file_id, version_number)) + }); - // Call the metric update handler through the public module function - let updated_metric = - update_metric_handler(&request.asset_id, user, metric_request).await?; + // Task 2: Get the most recent message to copy raw_llm_messages + let last_message_task = tokio::spawn(async move { + let mut conn = get_pg_pool().get().await?; + let last_message = messages::table + .filter(messages::chat_id.eq(&chat_id_clone1)) + .filter(messages::deleted_at.is_null()) + .limit(1) + .order_by(messages::created_at.desc()) + .first::(&mut conn) // Assuming Message derives Clone + .await + .ok(); + // Explicitly type the Ok variant + Ok::<_, anyhow::Error>(last_message) + }); - // Return the file information - ( - "metric".to_string(), - updated_metric.name, - updated_metric.id, - updated_metric.versions.len() as i32, // Get version number from versions length - ) - } - AssetType::DashboardFile => { - // Create a dashboard update request with only the restore_to_version parameter - let dashboard_request = DashboardUpdateRequest { - restore_to_version: Some(request.version_number), - ..Default::default() - }; + // Wait for initial tasks to complete + let (restore_result, last_message_result) = tokio::join!(restore_task, last_message_task); - // Call the dashboard update handler through the public module function - let updated_dashboard = - update_dashboard_handler(request.asset_id, dashboard_request, user).await?; + // Handle potential errors from spawned tasks + let (file_type, file_name, file_id, version_number) = restore_result??; + let last_message = last_message_result??; - // Return the file information - ( - "dashboard".to_string(), - updated_dashboard.dashboard.name, - updated_dashboard.dashboard.id, - updated_dashboard.dashboard.version_number, - ) - } - _ => { - return Err(anyhow!( - "Unsupported asset type for restoration: {:?}", - request.asset_type - )) - } - }; - - // Step 2: Get the most recent message to copy raw_llm_messages - // Fetch the most recent message for the chat to extract raw_llm_messages - let last_message = messages::table - .filter(messages::chat_id.eq(chat_id)) - .filter(messages::deleted_at.is_null()) - .limit(1) - // We need to use order here to get the latest message - .then_order_by(messages::created_at.desc()) - .first::(&mut conn) - .await - .ok(); - - // Create raw_llm_messages by copying from the previous message and adding restoration entries + // Step 3: Construct message details let tool_call_id = format!("call_{}", Uuid::new_v4().to_string().replace("-", "")); - - // Start with copied raw_llm_messages or an empty array let mut raw_llm_messages = if let Some(last_msg) = &last_message { - if let Ok(msgs) = serde_json::from_value::>(last_msg.raw_llm_messages.clone()) { + // Use clone here if last_message is Some(Message) + if let Ok(msgs) = serde_json::from_value::>(last_msg.raw_llm_messages.clone()) + { msgs } else { Vec::new() @@ -127,7 +146,6 @@ pub async fn restore_chat_handler( Vec::new() }; - // Add tool call message and tool response message raw_llm_messages.push(json!({ "name": "buster_super_agent", "role": "assistant", @@ -144,8 +162,6 @@ pub async fn restore_chat_handler( } ] })); - - // Add the tool response raw_llm_messages.push(json!({ "name": format!("restore_{}", file_type), "role": "tool", @@ -156,13 +172,10 @@ pub async fn restore_chat_handler( "tool_call_id": tool_call_id })); - // Step 3: Create a message with text and file responses - let message_id = Uuid::new_v4(); let now = Utc::now(); let timestamp = now.timestamp(); - // Create response messages array with both text and file response let response_messages = json!([ { "id": file_id.to_string(), @@ -174,21 +187,22 @@ pub async fn restore_chat_handler( "timestamp": timestamp } ], - "file_name": file_name, - "file_type": file_type, - "version_number": version_number, + "file_name": file_name, // file_name is already String, no clone needed if moved + "file_type": file_type, // file_type is already String, no clone needed if moved + "version_number": version_number, // version_number is i32 (Copy) "filter_version_id": null } ]); - // Create a Message object to insert + // Create Message object - requires Message to be Clone if used in multiple tasks + // Assuming Message derives Clone let message = Message { id: message_id, - request_message: None, // Empty request message as per requirement - response_messages: response_messages, + request_message: None, + response_messages, // This is Value, likely Clone reasoning: json!([]), title: "Version Restoration".to_string(), - raw_llm_messages: Value::Array(raw_llm_messages.clone()), + raw_llm_messages: Value::Array(raw_llm_messages), // raw_llm_messages moved here final_reasoning_message: Some(format!( "v{} was created by restoring v{}", version_number, request.version_number @@ -201,13 +215,8 @@ pub async fn restore_chat_handler( feedback: None, }; - // Insert the message - diesel::insert_into(messages::table) - .values(&message) - .execute(&mut conn) - .await?; - - // Create the message-to-file association + // Create MessageToFile object - requires MessageToFile to be Clone if used in multiple tasks + // Assuming MessageToFile derives Clone let message_to_file = MessageToFile { id: Uuid::new_v4(), message_id: message_id, @@ -219,23 +228,50 @@ pub async fn restore_chat_handler( version_number: version_number, }; - // Insert the message-to-file association into the database - diesel::insert_into(messages_to_files::table) - .values(&message_to_file) - .execute(&mut conn) - .await?; + // Step 4: Concurrently insert message, message_to_file, and update chat + // Clone necessary variables for final tasks + let message_clone = message.clone(); // Requires Message: Clone + let message_to_file_clone = message_to_file.clone(); // Requires MessageToFile: Clone + let chat_id_clone2 = *chat_id; + let request_asset_type_clone = request.asset_type; // AssetType is likely Copy + let file_id_clone = file_id; // file_id is Uuid (Copy) - // Step 4: Update the chat record with the latest file info - update(chats::table) - .filter(chats::id.eq(chat_id)) - .set(( - chats::most_recent_file_id.eq(Some(file_id)), - chats::most_recent_version_number.eq(Some(version_number)), - chats::most_recent_file_type.eq(Some(request.asset_type.to_string())), - chats::updated_at.eq(now), - )) - .execute(&mut conn) - .await?; + + let insert_message_task = tokio::spawn(async move { + let mut conn = get_pg_pool().get().await?; + diesel::insert_into(messages::table) + .values(&message_clone) // Use cloned message + .execute(&mut conn) + .await?; + Ok::<_, anyhow::Error>(()) // Explicit Ok type + }); + + let insert_mtf_task = tokio::spawn(async move { + let mut conn = get_pg_pool().get().await?; + diesel::insert_into(messages_to_files::table) + .values(&message_to_file_clone) // Use cloned mtf + .execute(&mut conn) + .await?; + Ok::<_, anyhow::Error>(()) // Explicit Ok type + }); + + let update_chat_task = tokio::spawn(async move { + let mut conn = get_pg_pool().get().await?; + update(chats::table) + .filter(chats::id.eq(&chat_id_clone2)) + .set(( + chats::most_recent_file_id.eq(Some(file_id_clone)), + chats::most_recent_version_number.eq(Some(version_number)), // version_number is Copy + chats::most_recent_file_type.eq(Some(request_asset_type_clone.to_string())), + chats::updated_at.eq(now), // now is Copy + )) + .execute(&mut conn) + .await?; + Ok::<_, anyhow::Error>(()) // Explicit Ok type + }); + + // Wait for final database operations using try_join_all for cleaner error handling + try_join_all(vec![insert_message_task, insert_mtf_task, update_chat_task]).await?; // Return the updated chat with messages get_chat_handler(chat_id, user, false).await