Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using TelegramSearchBot.Service.AI.LLM;
using Xunit;

namespace TelegramSearchBot.LLM.Test.Service.AI.LLM {
public class OpenAIToolCallNormalizationTests {
[Fact]
public void NormalizeToolCallId_EmptyValue_GeneratesNonEmptyFallback() {
var id = OpenAIService.NormalizeToolCallId(string.Empty);

Assert.False(string.IsNullOrWhiteSpace(id));
Assert.StartsWith("call_", id);
}

[Fact]
public void NormalizeToolCallId_NonEmptyValue_TrimsAndPreservesValue() {
var id = OpenAIService.NormalizeToolCallId(" call_123 ");

Assert.Equal("call_123", id);
}

[Fact]
public void NormalizeToolCallName_EmptyValue_ReturnsUnknown() {
var name = OpenAIService.NormalizeToolCallName(" ");

Assert.Equal("unknown", name);
}

[Fact]
public void NormalizeToolCallName_NonEmptyValue_TrimsAndPreservesValue() {
var name = OpenAIService.NormalizeToolCallName(" tool_1 ");

Assert.Equal("tool_1", name);
}

[Fact]
public void NormalizeToolCallArguments_EmptyValue_ReturnsEmptyJsonObject() {
var arguments = OpenAIService.NormalizeToolCallArguments("");

Assert.Equal("{}", arguments);
}

[Fact]
public void NormalizeToolCallArguments_WhitespaceValue_ReturnsEmptyJsonObject() {
var arguments = OpenAIService.NormalizeToolCallArguments(" ");

Assert.Equal("{}", arguments);
}

[Fact]
public void DeserializeToolArgumentsForDisplay_ConvertsNonStringValues() {
var arguments = OpenAIService.DeserializeToolArgumentsForDisplay("{\"count\":2,\"enabled\":true,\"text\":\"hello\"}");

Assert.Equal("2", arguments["count"]);
Assert.Equal("True", arguments["enabled"]);
Assert.Equal("hello", arguments["text"]);
}

[Fact]
public void DeserializeToolArgumentsForDisplay_InvalidJson_ReturnsEmptyDictionary() {
var arguments = OpenAIService.DeserializeToolArgumentsForDisplay("{not json");

Assert.Empty(arguments);
}
}
}
50 changes: 39 additions & 11 deletions TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,35 @@
public StringBuilder Arguments { get; } = new StringBuilder();
}

internal static string NormalizeToolCallId(string toolCallId) {
return string.IsNullOrWhiteSpace(toolCallId)
? $"call_{Guid.NewGuid():N}"
: toolCallId.Trim();
}

internal static string NormalizeToolCallName(string toolCallName) {
return string.IsNullOrWhiteSpace(toolCallName)
? "unknown"
: toolCallName.Trim();
}

internal static string NormalizeToolCallArguments(string argumentsJson) {
return string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson;
}

internal static Dictionary<string, string> DeserializeToolArgumentsForDisplay(string argumentsJson) {
try {
var normalized = NormalizeToolCallArguments(argumentsJson);
var values = JsonConvert.DeserializeObject<Dictionary<string, object>>(normalized);
return values?.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value?.ToString() ?? string.Empty)
?? new Dictionary<string, string>();
} catch {
return new Dictionary<string, string>();
}
}

private readonly ILogger<OpenAIService> _logger;
public static string _botName;
public string BotName {
Expand Down Expand Up @@ -1022,13 +1051,16 @@
// Build the assistant message with tool calls
chatToolCalls = new List<ChatToolCall>();
foreach (var (index, acc) in toolCallAccumulators) {
if (acc.Id == null) {
if (string.IsNullOrWhiteSpace(acc.Id)) {
_logger.LogWarning("{ServiceName}: Tool call at index {Index} has no ID, generating fallback.", ServiceName, index);
}
var toolCallId = NormalizeToolCallId(acc.Id);
var toolName = NormalizeToolCallName(acc.Name);
var argumentsJson = NormalizeToolCallArguments(acc.Arguments.ToString());
chatToolCalls.Add(ChatToolCall.CreateFunctionToolCall(
acc.Id ?? $"call_{Guid.NewGuid():N}",
acc.Name ?? "unknown",
BinaryData.FromString(acc.Arguments.Length > 0 ? acc.Arguments.ToString() : "{}")));
toolCallId,
toolName,
BinaryData.FromString(argumentsJson)));
}

var assistantMessage = new AssistantChatMessage(chatToolCalls);
Expand All @@ -1041,15 +1073,13 @@

var toolIndicators = new StringBuilder();
foreach (var toolCall in chatToolCalls) {
var argsJson = toolCall.FunctionArguments?.ToString() ?? "{}";
var argsDict = JsonConvert.DeserializeObject<Dictionary<string, string>>(argsJson)
?? new Dictionary<string, string>();
var argsDict = DeserializeToolArgumentsForDisplay(toolCall.FunctionArguments?.ToString());
toolIndicators.Append(McpToolHelper.FormatToolCallDisplay(toolCall.FunctionName, argsDict));
}
currentMessageContentBuilder.Append(toolIndicators.ToString());
} catch (Exception ex) {
_logger.LogError(ex, "{ServiceName}: Error building tool calls, returning error to LLM for self-correction", ServiceName);
var errorMsg = $"Error processing tool call: {ex.Message}. Please check your tool call parameters and try again.";
const string errorMsg = "Tool call failed before execution due to malformed tool metadata. Please verify the tool name and parameters, then try again.";
providerHistory.Add(new UserChatMessage(errorMsg));
continue;
}
Expand All @@ -1065,9 +1095,7 @@
string toolResultString;
try {
// Parse arguments from JSON
var argsJson = toolCall.FunctionArguments?.ToString() ?? "{}";
var argsDict = JsonConvert.DeserializeObject<Dictionary<string, string>>(argsJson)
?? new Dictionary<string, string>();
var argsDict = DeserializeToolArgumentsForDisplay(toolCall.FunctionArguments?.ToString());

var toolContext = new ToolContext { ChatId = ChatId, UserId = message.FromUserId, MessageId = message.MessageId };
object toolResultObject = await McpToolHelper.ExecuteRegisteredToolAsync(toolName, argsDict, toolContext);
Expand Down Expand Up @@ -1380,7 +1408,7 @@
/// Extract reasoning_content from AssistantChatMessage if available.
/// For thinking mode models, the reasoning process is returned separately.
/// </summary>
private static string? GetAssistantReasoningContent(AssistantChatMessage assistantMsg) {

Check warning on line 1411 in TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
// Try to access reasoning content - OpenAI Chat SDK may store it in various ways
// The reasoning_content is typically available via reflection or specific properties
try {
Expand Down
4 changes: 4 additions & 0 deletions TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
<Nullable>warnings</Nullable>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="TelegramSearchBot.LLM.Test" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Anthropic" Version="12.16.0" />
<PackageReference Include="Google_GenerativeAI" Version="3.6.6" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ public async Task MultipleSearches_ShouldMaintainConsistentPerformance() {
SearchType = SearchType.Vector
};

// Warm up FAISS/index loading so the measured loop tracks steady-state search performance.
var warmupResult = await _faissVectorService.Search(searchOption);
Assert.NotNull(warmupResult);

// Act - 执行多次搜索
for (int i = 0; i < searchCount; i++) {
var stopwatch = Stopwatch.StartNew();
Expand Down
Loading