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
14 changes: 14 additions & 0 deletions samples/Demo/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"NLWebNet": {
"ToolSelectionEnabled": true,
"EnableDetailedLogging": true
},
"Logging": {
"LogLevel": {
"Default": "Information",
"NLWebNet": "Debug",
"NLWebNet.Services.ToolSelector": "Debug",
"NLWebNet.Services.QueryProcessor": "Debug"
}
}
}
3 changes: 3 additions & 0 deletions src/NLWebNet/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public static IServiceCollection AddNLWebNet(this IServiceCollection services, A
services.AddScoped<INLWebService, NLWebService>();
services.AddScoped<IQueryProcessor, QueryProcessor>();
services.AddScoped<IResultGenerator, ResultGenerator>();
services.AddScoped<IToolSelector, ToolSelector>();

// Register MCP services
services.AddScoped<IMcpService, McpService>(); // Register default data backend (can be overridden)
Expand Down Expand Up @@ -76,6 +77,7 @@ public static IServiceCollection AddNLWebNet<TDataBackend>(this IServiceCollecti
services.AddScoped<INLWebService, NLWebService>();
services.AddScoped<IQueryProcessor, QueryProcessor>();
services.AddScoped<IResultGenerator, ResultGenerator>();
services.AddScoped<IToolSelector, ToolSelector>();

// Register MCP services
services.AddScoped<IMcpService, McpService>();
Expand Down Expand Up @@ -152,6 +154,7 @@ public static IServiceCollection AddNLWebNetMultiBackend(this IServiceCollection
});

services.AddScoped<IQueryProcessor, QueryProcessor>();
services.AddScoped<IToolSelector, ToolSelector>();
services.AddScoped<IResultGenerator>(provider =>
{
var options = provider.GetRequiredService<IOptions<NLWebOptions>>();
Expand Down
6 changes: 6 additions & 0 deletions src/NLWebNet/Models/NLWebOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,10 @@ public class NLWebOptions
/// Multi-backend configuration options. When enabled, overrides single backend behavior.
/// </summary>
public MultiBackendOptions MultiBackend { get; set; } = new();

/// <summary>
/// Whether to enable tool selection framework for routing queries to appropriate tools.
/// When disabled, maintains existing behavior for backward compatibility.
/// </summary>
public bool ToolSelectionEnabled { get; set; } = false;
}
24 changes: 24 additions & 0 deletions src/NLWebNet/Services/IToolSelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using NLWebNet.Models;

namespace NLWebNet.Services;

/// <summary>
/// Interface for selecting appropriate tools based on query intent.
/// </summary>
public interface IToolSelector
{
/// <summary>
/// Selects the appropriate tool based on the query intent and context.
/// </summary>
/// <param name="request">The NLWeb request containing the query and context</param>
/// <param name="cancellationToken">Cancellation token for async operations</param>
/// <returns>The selected tool name, or null if no specific tool is needed</returns>
Task<string?> SelectToolAsync(NLWebRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Determines if tool selection is needed for the given request.
/// </summary>
/// <param name="request">The NLWeb request to analyze</param>
/// <returns>True if tool selection should be performed, false otherwise</returns>
bool ShouldSelectTool(NLWebRequest request);
}
17 changes: 16 additions & 1 deletion src/NLWebNet/Services/QueryProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,32 @@ namespace NLWebNet.Services;
public class QueryProcessor : IQueryProcessor
{
private readonly ILogger<QueryProcessor> _logger;
private readonly IToolSelector? _toolSelector;

public QueryProcessor(ILogger<QueryProcessor> logger)
public QueryProcessor(ILogger<QueryProcessor> logger, IToolSelector? toolSelector = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_toolSelector = toolSelector;
}

/// <inheritdoc />
public async Task<string> ProcessQueryAsync(NLWebRequest request, CancellationToken cancellationToken = default)
{
_logger.LogDebug("Processing query for request {QueryId}", request.QueryId);

// Perform tool selection if available and enabled
if (_toolSelector != null && _toolSelector.ShouldSelectTool(request))
{
var selectedTool = await _toolSelector.SelectToolAsync(request, cancellationToken);
if (!string.IsNullOrEmpty(selectedTool))
{
_logger.LogDebug("Tool selection complete: {Tool} for request {QueryId}", selectedTool, request.QueryId);
// Store the selected tool in the request for downstream processing
// Note: This is a minimal implementation - the tool selection result could be used
// by other components in the pipeline
}
}

// If decontextualized query is already provided, use it
if (!string.IsNullOrEmpty(request.DecontextualizedQuery))
{
Expand Down
125 changes: 125 additions & 0 deletions src/NLWebNet/Services/ToolSelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NLWebNet.Models;

namespace NLWebNet.Services;

/// <summary>
/// Implementation of tool selection logic that routes queries to appropriate tools based on intent.
/// </summary>
public class ToolSelector : IToolSelector
{
private readonly ILogger<ToolSelector> _logger;
private readonly NLWebOptions _options;

/// <summary>
/// Constants for tool names and associated keywords
/// </summary>
private static class ToolConstants
{
// Tool names
public const string SearchTool = "search";
public const string CompareTool = "compare";
public const string DetailsTool = "details";
public const string EnsembleTool = "ensemble";

// Keywords for each tool
public static readonly string[] SearchKeywords = { "search", "find", "look for", "locate" };
public static readonly string[] CompareKeywords = { "compare", "difference", "versus", "vs", "contrast" };
public static readonly string[] DetailsKeywords = { "details", "information about", "tell me about", "describe" };
public static readonly string[] EnsembleKeywords = { "recommend", "suggest", "what should", "ensemble", "set of" };
}

public ToolSelector(ILogger<ToolSelector> logger, IOptions<NLWebOptions> options)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}

/// <inheritdoc />
public async Task<string?> SelectToolAsync(NLWebRequest request, CancellationToken cancellationToken = default)
{
if (!ShouldSelectTool(request))
{
_logger.LogDebug("Tool selection not needed for request {QueryId}", request.QueryId);
return null;
}

_logger.LogDebug("Selecting tool for query: {Query}", request.Query);

// Simple intent-based tool selection
// In a full implementation, this would use more sophisticated intent analysis
var selectedTool = await AnalyzeQueryIntentAsync(request.Query, cancellationToken);

_logger.LogDebug("Selected tool: {Tool} for query {QueryId}", selectedTool ?? "none", request.QueryId);
return selectedTool;
}

/// <inheritdoc />
public bool ShouldSelectTool(NLWebRequest request)
{
// Don't perform tool selection if:
// 1. Tool selection is disabled in configuration
// 2. Generate mode is used (maintain existing behavior)
// 3. Request already has a decontextualized query (already processed)

if (!_options.ToolSelectionEnabled)
{
return false;
}

if (request.Mode == QueryMode.Generate)
{
_logger.LogDebug("Skipping tool selection for Generate mode to maintain existing behavior");
return false;
}

if (!string.IsNullOrEmpty(request.DecontextualizedQuery))
{
_logger.LogDebug("Skipping tool selection for request with decontextualized query");
return false;
}

return true;
}

/// <summary>
/// Analyzes the query intent to determine the appropriate tool.
/// This is a simplified implementation - production would use more sophisticated NLP.
/// </summary>
private Task<string?> AnalyzeQueryIntentAsync(string query, CancellationToken cancellationToken)
{
var queryLower = query.ToLowerInvariant();

// Basic keyword-based intent detection
// In production, this would use ML models or more sophisticated analysis

if (ContainsKeywords(queryLower, ToolConstants.SearchKeywords))
{
return Task.FromResult<string?>(ToolConstants.SearchTool);
}

if (ContainsKeywords(queryLower, ToolConstants.CompareKeywords))
{
return Task.FromResult<string?>(ToolConstants.CompareTool);
}

if (ContainsKeywords(queryLower, ToolConstants.DetailsKeywords))
{
return Task.FromResult<string?>(ToolConstants.DetailsTool);
}

if (ContainsKeywords(queryLower, ToolConstants.EnsembleKeywords))
{
return Task.FromResult<string?>(ToolConstants.EnsembleTool);
}

// Default to search tool for general queries
return Task.FromResult<string?>(ToolConstants.SearchTool);
}

private static bool ContainsKeywords(string text, string[] keywords)
{
return keywords.Any(keyword => text.Contains(keyword));
}
}
76 changes: 76 additions & 0 deletions tests/NLWebNet.Tests/Services/QueryProcessorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,80 @@ public async Task ProcessQueryAsync_ReturnsQuery()
// Assert
Assert.AreEqual("test query", result);
}

[TestMethod]
public async Task ProcessQueryAsync_WithToolSelector_CallsToolSelection()
{
// Arrange
var toolSelectorLogger = new TestLogger<ToolSelector>();
var nlWebOptions = new NLWebOptions { ToolSelectionEnabled = true };
var options = Options.Create(nlWebOptions);
var toolSelector = new ToolSelector(toolSelectorLogger, options);
var queryProcessorWithToolSelector = new QueryProcessor(_logger, toolSelector);

var request = new NLWebRequest
{
Query = "search for something",
Mode = QueryMode.List,
QueryId = "test-query-id"
};

// Act
var result = await queryProcessorWithToolSelector.ProcessQueryAsync(request, CancellationToken.None);

// Assert
Assert.AreEqual("search for something", result);
// The tool selection should have been called but not affect the final result
// since we're not changing the query processing behavior yet
}

[TestMethod]
public async Task ProcessQueryAsync_WithToolSelectorDisabled_DoesNotCallToolSelection()
{
// Arrange
var toolSelectorLogger = new TestLogger<ToolSelector>();
var nlWebOptions = new NLWebOptions { ToolSelectionEnabled = false };
var options = Options.Create(nlWebOptions);
var toolSelector = new ToolSelector(toolSelectorLogger, options);
var queryProcessorWithToolSelector = new QueryProcessor(_logger, toolSelector);

var request = new NLWebRequest
{
Query = "search for something",
Mode = QueryMode.List,
QueryId = "test-query-id"
};

// Act
var result = await queryProcessorWithToolSelector.ProcessQueryAsync(request, CancellationToken.None);

// Assert
Assert.AreEqual("search for something", result);
// Tool selection should not have been called
}

[TestMethod]
public async Task ProcessQueryAsync_WithGenerateMode_SkipsToolSelection()
{
// Arrange
var toolSelectorLogger = new TestLogger<ToolSelector>();
var nlWebOptions = new NLWebOptions { ToolSelectionEnabled = true };
var options = Options.Create(nlWebOptions);
var toolSelector = new ToolSelector(toolSelectorLogger, options);
var queryProcessorWithToolSelector = new QueryProcessor(_logger, toolSelector);

var request = new NLWebRequest
{
Query = "generate something",
Mode = QueryMode.Generate,
QueryId = "test-query-id"
};

// Act
var result = await queryProcessorWithToolSelector.ProcessQueryAsync(request, CancellationToken.None);

// Assert
Assert.AreEqual("generate something", result);
// Tool selection should have been skipped for Generate mode
}
}
Loading