From 1b08655dfa3ee4e03653039ffa9d2c8bc29ded5c Mon Sep 17 00:00:00 2001 From: cryptooda Date: Tue, 6 Jan 2026 23:25:14 +0700 Subject: [PATCH] Enhance LlmController and AiChat component for improved progress updates and message handling - Introduced a new method in LlmController to generate descriptive messages for tool execution results, improving clarity in progress updates. - Updated AiChat component to display progress messages in chat history, enhancing user experience during tool execution. - Refactored progress indicator styling for better visual feedback and readability. - Adjusted backtest query handling in LlmController to optimize iteration counts based on query type, improving performance and user interaction. - Enhanced documentation for backtest tools in BacktestMcpTools to clarify usage and parameters, ensuring better understanding for developers. --- src/Managing.Api/Controllers/LlmController.cs | 183 ++++++++++++++++-- src/Managing.Mcp/McpTools/BacktestMcpTools.cs | 18 +- .../src/components/organism/AiChat.tsx | 91 +++++---- .../src/services/aiChatService.ts | 15 +- 4 files changed, 250 insertions(+), 57 deletions(-) diff --git a/src/Managing.Api/Controllers/LlmController.cs b/src/Managing.Api/Controllers/LlmController.cs index d312ab8b..8fa26dde 100644 --- a/src/Managing.Api/Controllers/LlmController.cs +++ b/src/Managing.Api/Controllers/LlmController.cs @@ -348,10 +348,11 @@ public class LlmController : BaseController logger.LogInformation("Successfully executed tool {ToolName} in iteration {Iteration} for user {UserId}", toolCall.Name, iteration, user.Id); + var resultMessage = GenerateToolResultMessage(toolCall.Name, result); await SendProgressUpdate(connectionId, hubContext, logger, new LlmProgressUpdate { Type = "tool_result", - Message = $"Tool {toolCall.Name} completed successfully", + Message = resultMessage, Iteration = iteration, MaxIterations = maxIterations, ToolName = toolCall.Name @@ -678,13 +679,17 @@ public class LlmController : BaseController if (lastMessage.Contains("bundle") || lastMessage.Contains("compare") || lastMessage.Contains("all backtests")) return 5; - // Backtest detail requests need 4 iterations (list → get_by_id → analyze → format) + // Backtest detail requests with "analyze" or "detail" need more iterations for deep analysis if (lastMessage.Contains("backtest") && - (lastMessage.Contains("detail") || lastMessage.Contains("analyze") || lastMessage.Contains("show") || - lastMessage.Contains("this") || lastMessage.Contains("that") || lastMessage.Contains("best") || - lastMessage.Contains("top") || lastMessage.Contains("recent"))) + (lastMessage.Contains("detail") || lastMessage.Contains("analyze") || lastMessage.Contains("position"))) return 4; + // Simple backtest queries ("best", "top", "show") only need 2 iterations (fetch + respond) + if (lastMessage.Contains("backtest") && + (lastMessage.Contains("best") || lastMessage.Contains("top") || lastMessage.Contains("show") || + lastMessage.Contains("recent") || lastMessage.Contains("latest"))) + return 2; + // General analysis queries if (lastMessage.Contains("analyze")) return 4; @@ -713,10 +718,12 @@ public class LlmController : BaseController TOOL USAGE: - Use tools ONLY for system operations: backtesting, retrieving user data, or real-time market data - When users ask about their data, use tools proactively with smart defaults: - * "Best backtest" → get_backtests_paginated(sortBy='Score', sortOrder='desc', pageSize=10) + * "Best backtest" → get_backtests_paginated(sortBy='Score', sortOrder='desc', pageSize=1) + * "Top 5 backtests" → get_backtests_paginated(sortBy='Score', sortOrder='desc', pageSize=5) * "My indicators" → list_indicators() - * "Recent backtests" → get_backtests_paginated(sortOrder='desc', pageSize=20) + * "Recent backtests" → get_backtests_paginated(sortBy='StartDate', sortOrder='desc', pageSize=10) * "Bundle backtest analysis" → analyze_bundle_backtest(bundleRequestId='X') + - IMPORTANT: get_backtests_paginated returns summary data. Only call get_backtest_by_id if user explicitly asks for position details or deeper analysis. ERROR HANDLING: - If a tool returns a database connection error, wait a moment and retry once (these are often transient) @@ -739,12 +746,14 @@ public class LlmController : BaseController - If multiple backtests were listed, use the most recently mentioned one or the top-ranked one - NEVER ask user for IDs/names that were already provided in conversation - 3. BACKTEST DETAIL WORKFLOW: - When user requests backtest details/analysis: - a) If backtest ID is in conversation → IMMEDIATELY call get_backtest_by_id(id) - b) If no ID but refers to "best/top" → call get_backtests_paginated(sortBy='Score', sortOrder='desc', pageSize=1) THEN get_backtest_by_id() - c) If no ID but refers to "recent/latest" → call get_backtests_paginated(sortOrder='desc', pageSize=1) THEN get_backtest_by_id() - d) If completely ambiguous → ask ONCE for clarification, then proceed + 3. BACKTEST DETAIL WORKFLOW (TOKEN-OPTIMIZED): + When user requests backtest information: + a) If backtest ID is in conversation AND user asks for positions/details → call get_backtest_by_id(id) + b) If no ID but refers to "best/top N" → call get_backtests_paginated(sortBy='Score', sortOrder='desc', pageSize=N) + c) If no ID but refers to "recent/latest N" → call get_backtests_paginated(sortBy='StartDate', sortOrder='desc', pageSize=N) + d) For simple queries like "show my best backtest" → get_backtests_paginated is sufficient (includes key metrics) + e) Only call get_backtest_by_id for DETAILED analysis when user explicitly needs position-level data + f) If completely ambiguous → ask ONCE for clarification, then proceed 4. INDICATOR WORKFLOW: When user asks about indicators: @@ -1058,6 +1067,154 @@ public class LlmController : BaseController return null; } + /// + /// Generates a descriptive message for tool execution results + /// + private static string GenerateToolResultMessage(string toolName, object result) + { + try + { + // Try to parse result as JSON to extract meaningful information + var jsonResult = result as JsonElement? ?? JsonSerializer.Deserialize(JsonSerializer.Serialize(result)); + + switch (toolName.ToLowerInvariant()) + { + case "get_backtests_paginated": + if (jsonResult.TryGetProperty("items", out var backtestItems) && backtestItems.ValueKind == JsonValueKind.Array) + { + var count = backtestItems.GetArrayLength(); + var totalCount = jsonResult.TryGetProperty("totalCount", out var total) ? total.GetInt32() : count; + return $"Retrieved {count} backtest(s) out of {totalCount} total"; + } + break; + + case "get_backtest_by_id": + if (jsonResult.TryGetProperty("name", out var backtestName)) + { + var name = backtestName.GetString() ?? "Unknown"; + var score = jsonResult.TryGetProperty("score", out var scoreVal) ? $" (Score: {scoreVal.GetDouble():F1})" : ""; + return $"Retrieved backtest '{name}'{score}"; + } + return "Retrieved backtest details"; + + case "get_bundle_backtests_paginated": + if (jsonResult.TryGetProperty("items", out var bundleItems) && bundleItems.ValueKind == JsonValueKind.Array) + { + var count = bundleItems.GetArrayLength(); + var totalCount = jsonResult.TryGetProperty("totalCount", out var total) ? total.GetInt32() : count; + return $"Retrieved {count} bundle backtest(s) out of {totalCount} total"; + } + break; + + case "get_bundle_backtest_by_id": + if (jsonResult.TryGetProperty("botName", out var bundleName)) + { + var name = bundleName.GetString() ?? "Unknown"; + return $"Retrieved bundle backtest '{name}'"; + } + return "Retrieved bundle backtest details"; + + case "analyze_bundle_backtest": + if (jsonResult.TryGetProperty("totalBacktests", out var totalBacktests)) + { + var count = totalBacktests.GetInt32(); + var avgScore = jsonResult.TryGetProperty("averageScore", out var score) ? $", Avg Score: {score.GetDouble():F1}" : ""; + return $"Analyzed {count} backtest(s) in bundle{avgScore}"; + } + return "Completed bundle backtest analysis"; + + case "list_indicators": + if (jsonResult.ValueKind == JsonValueKind.Array) + { + var count = jsonResult.GetArrayLength(); + return $"Retrieved {count} indicator(s)"; + } + break; + + case "get_indicator_info": + if (jsonResult.TryGetProperty("type", out var indicatorType)) + { + var type = indicatorType.GetString() ?? "Unknown"; + return $"Retrieved info for indicator '{type}'"; + } + return "Retrieved indicator information"; + + case "get_tickers": + if (jsonResult.ValueKind == JsonValueKind.Array) + { + var count = jsonResult.GetArrayLength(); + return $"Retrieved {count} ticker(s)"; + } + break; + + case "get_candles": + if (jsonResult.ValueKind == JsonValueKind.Array) + { + var count = jsonResult.GetArrayLength(); + return $"Retrieved {count} candle(s)"; + } + break; + + case "get_agents_paginated": + if (jsonResult.TryGetProperty("items", out var agentItems) && agentItems.ValueKind == JsonValueKind.Array) + { + var count = agentItems.GetArrayLength(); + var totalCount = jsonResult.TryGetProperty("totalCount", out var total) ? total.GetInt32() : count; + return $"Retrieved {count} agent(s) out of {totalCount} total"; + } + break; + + case "get_online_agents": + if (jsonResult.ValueKind == JsonValueKind.Array) + { + var count = jsonResult.GetArrayLength(); + return $"Found {count} online agent(s)"; + } + break; + + case "run_backtest": + if (jsonResult.TryGetProperty("backtestId", out var btId)) + { + return $"Started backtest (ID: {btId.GetString()})"; + } + return "Started backtest execution"; + + case "run_bundle_backtest": + if (jsonResult.TryGetProperty("bundleRequestId", out var bundleId)) + { + return $"Started bundle backtest (ID: {bundleId.GetString()})"; + } + return "Started bundle backtest execution"; + + case "delete_backtest": + case "delete_bundle_backtest": + return $"Successfully deleted {toolName.Replace("delete_", "").Replace("_", " ")}"; + + case "delete_backtests_by_ids": + if (jsonResult.TryGetProperty("deletedCount", out var deletedCount)) + { + return $"Deleted {deletedCount.GetInt32()} backtest(s)"; + } + return "Deleted backtests"; + + case "delete_backtests_by_filters": + if (jsonResult.TryGetProperty("deletedCount", out var filteredDeletedCount)) + { + return $"Deleted {filteredDeletedCount.GetInt32()} backtest(s) matching filters"; + } + return "Deleted backtests matching filters"; + } + + // Default message if no specific handler + return $"Tool {toolName} completed successfully"; + } + catch (Exception) + { + // If parsing fails, return generic message + return $"Tool {toolName} completed successfully"; + } + } + /// /// Helper method to send progress update via SignalR /// diff --git a/src/Managing.Mcp/McpTools/BacktestMcpTools.cs b/src/Managing.Mcp/McpTools/BacktestMcpTools.cs index f3fdf36d..f334b628 100644 --- a/src/Managing.Mcp/McpTools/BacktestMcpTools.cs +++ b/src/Managing.Mcp/McpTools/BacktestMcpTools.cs @@ -24,7 +24,7 @@ public class BacktestMcpTools : BaseMcpTool new McpToolDefinition { Name = "get_backtests_paginated", - Description = "Retrieves paginated backtests with filtering and sorting capabilities. Supports filters for score, winrate, drawdown, tickers, indicators, duration, and trading type.", + Description = "Retrieves paginated backtests with filtering and sorting capabilities. This is the PRIMARY tool for finding backtests. Use this when user asks for 'best backtest' (pageSize=1), 'top 5 backtests' (pageSize=5), or browsing backtests (default pageSize=10). Only use get_backtest_by_id when you already have a specific backtest ID. Supports filters for score, winrate, drawdown, tickers, indicators, duration, and trading type.", Parameters = new Dictionary { ["page"] = new McpParameterDefinition @@ -37,9 +37,9 @@ public class BacktestMcpTools : BaseMcpTool ["pageSize"] = new McpParameterDefinition { Type = "integer", - Description = "Number of items per page (defaults to 50, max 100)", + Description = "Number of items per page (defaults to 10, max 100). Use 1 for 'best/worst', 5 for 'top 5', 10 for general browsing.", Required = false, - DefaultValue = 50 + DefaultValue = 10 }, ["sortBy"] = new McpParameterDefinition { @@ -126,7 +126,7 @@ public class BacktestMcpTools : BaseMcpTool new McpToolDefinition { Name = "get_backtest_by_id", - Description = "Retrieves a specific backtest by its ID for the authenticated user.", + Description = "Retrieves a specific backtest by its ID with full position details. ONLY use this when you have a specific backtest ID (from previous conversation or from get_backtests_paginated results). DO NOT use this for queries like 'best backtest' or 'top backtests' - use get_backtests_paginated instead.", Parameters = new Dictionary { ["id"] = new McpParameterDefinition @@ -206,7 +206,7 @@ public class BacktestMcpTools : BaseMcpTool new McpToolDefinition { Name = "get_bundle_backtests_paginated", - Description = "Retrieves paginated bundle backtest requests with filtering and sorting capabilities.", + Description = "Retrieves paginated bundle backtest requests with filtering and sorting capabilities. Use appropriate page size for token efficiency: 1 for latest bundle, 5 for top 5, 10 for general browsing.", Parameters = new Dictionary { ["page"] = new McpParameterDefinition @@ -219,9 +219,9 @@ public class BacktestMcpTools : BaseMcpTool ["pageSize"] = new McpParameterDefinition { Type = "integer", - Description = "Number of items per page (defaults to 50, max 100)", + Description = "Number of items per page (defaults to 10, max 100). Use 1 for latest, 5 for top 5, 10 for general browsing.", Required = false, - DefaultValue = 50 + DefaultValue = 10 }, ["sortBy"] = new McpParameterDefinition { @@ -455,7 +455,7 @@ public class BacktestMcpTools : BaseMcpTool public async Task ExecuteGetBacktestsPaginated(User user, Dictionary? parameters) { var page = GetParameterValue(parameters, "page", 1); - var pageSize = GetParameterValue(parameters, "pageSize", 50); + var pageSize = GetParameterValue(parameters, "pageSize", 10); var sortByString = GetParameterValue(parameters, "sortBy", "Score"); var sortOrder = GetParameterValue(parameters, "sortOrder", "desc"); var scoreMin = GetParameterValue(parameters, "scoreMin", null); @@ -551,7 +551,7 @@ public class BacktestMcpTools : BaseMcpTool public async Task ExecuteGetBundleBacktestsPaginated(User user, Dictionary? parameters) { var page = GetParameterValue(parameters, "page", 1); - var pageSize = GetParameterValue(parameters, "pageSize", 50); + var pageSize = GetParameterValue(parameters, "pageSize", 10); var sortByString = GetParameterValue(parameters, "sortBy", "CreatedAt"); var sortOrder = GetParameterValue(parameters, "sortOrder", "desc"); var statusString = GetParameterValue(parameters, "status", null); diff --git a/src/Managing.WebApp/src/components/organism/AiChat.tsx b/src/Managing.WebApp/src/components/organism/AiChat.tsx index 116cea01..2c003b51 100644 --- a/src/Managing.WebApp/src/components/organism/AiChat.tsx +++ b/src/Managing.WebApp/src/components/organism/AiChat.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect } from 'react' import { LlmClient } from '../../generated/ManagingApi' -import { LlmMessage, LlmChatResponse, LlmProgressUpdate } from '../../generated/ManagingApiTypes' -import { AiChatService } from '../../services/aiChatService' +import { LlmMessage, LlmChatResponse } from '../../generated/ManagingApiTypes' +import { AiChatService, LlmProgressUpdate } from '../../services/aiChatService' import useApiUrlStore from '../../app/store/apiStore' interface Message { @@ -35,7 +35,7 @@ function AiChat({ onClose }: AiChatProps): JSX.Element { const [historyIndex, setHistoryIndex] = useState(-1) const [tempInput, setTempInput] = useState('') const messagesEndRef = useRef(null) - const { apiUrl, userToken } = useApiUrlStore() + const { apiUrl } = useApiUrlStore() useEffect(() => { scrollToBottom() @@ -112,6 +112,20 @@ function AiChat({ onClose }: AiChatProps): JSX.Element { lastUpdate = update setCurrentProgress(update) + // Add progress messages to chat history (except final_response) + if (update.type !== 'final_response') { + const progressMessage: Message = { + role: 'progress', + content: update.message || '', + timestamp: new Date(), + progressType: update.type, + iteration: update.iteration, + maxIterations: update.maxIterations, + toolName: update.toolName + } + setMessages(prev => [...prev, progressMessage]) + } + // Handle different update types if (update.type === 'error') { const errorMessage: Message = { @@ -270,20 +284,40 @@ function AiChat({ onClose }: AiChatProps): JSX.Element { {messages.filter(m => m.role !== 'system').map((message, index) => (
-
-

{message.content}

-

- {message.timestamp.toLocaleTimeString()} -

-
+ {message.role === 'progress' ? ( +
+ +
+ ) : ( +
+

{message.content}

+

+ {message.timestamp.toLocaleTimeString()} +

+
+ )}
))} @@ -385,31 +419,20 @@ function ProgressIndicator({ progress }: { progress: LlmProgressUpdate }): JSX.E } return ( -
+
- {getIcon()} - + {getIcon()} + {progress.message}
- {progress.iteration && progress.maxIterations && ( -
- - Iteration {progress.iteration}/{progress.maxIterations} -
- )} - {progress.toolName && ( -
- +
+ {progress.toolName} {progress.toolArguments && Object.keys(progress.toolArguments).length > 0 && ( - + ({Object.keys(progress.toolArguments).length} args) )} @@ -418,7 +441,7 @@ function ProgressIndicator({ progress }: { progress: LlmProgressUpdate }): JSX.E )} {progress.error && ( -
+
{progress.error}
)} diff --git a/src/Managing.WebApp/src/services/aiChatService.ts b/src/Managing.WebApp/src/services/aiChatService.ts index ca813191..23a918ea 100644 --- a/src/Managing.WebApp/src/services/aiChatService.ts +++ b/src/Managing.WebApp/src/services/aiChatService.ts @@ -1,8 +1,21 @@ import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr' import { LlmClient } from '../generated/ManagingApi' -import { LlmChatRequest, LlmChatResponse, LlmMessage, LlmProgressUpdate } from '../generated/ManagingApiTypes' +import { LlmChatRequest, LlmChatResponse, LlmMessage } from '../generated/ManagingApiTypes' import { Cookies } from 'react-cookie' +export interface LlmProgressUpdate { + type: string + message: string + iteration?: number + maxIterations?: number + toolName?: string + toolArguments?: Record + content?: string + response?: LlmChatResponse + error?: string + timestamp?: Date +} + export class AiChatService { private llmClient: LlmClient private baseUrl: string