Skip to content

Commit 9f36a1c

Browse files
authored
Merge pull request #46 from jongalloway/copilot/fix-35
Implement Tool Selection Framework for NLWeb June 2025 Release
2 parents 7f04a03 + 5b86287 commit 9f36a1c

File tree

10 files changed

+628
-2
lines changed

10 files changed

+628
-2
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"NLWebNet": {
3+
"ToolSelectionEnabled": true,
4+
"EnableDetailedLogging": true
5+
},
6+
"Logging": {
7+
"LogLevel": {
8+
"Default": "Information",
9+
"NLWebNet": "Debug",
10+
"NLWebNet.Services.ToolSelector": "Debug",
11+
"NLWebNet.Services.QueryProcessor": "Debug"
12+
}
13+
}
14+
}

src/NLWebNet/Extensions/ServiceCollectionExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public static IServiceCollection AddNLWebNet(this IServiceCollection services, A
3636
services.AddScoped<INLWebService, NLWebService>();
3737
services.AddScoped<IQueryProcessor, QueryProcessor>();
3838
services.AddScoped<IResultGenerator, ResultGenerator>();
39+
services.AddScoped<IToolSelector, ToolSelector>();
3940

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

8082
// Register MCP services
8183
services.AddScoped<IMcpService, McpService>();
@@ -152,6 +154,7 @@ public static IServiceCollection AddNLWebNetMultiBackend(this IServiceCollection
152154
});
153155

154156
services.AddScoped<IQueryProcessor, QueryProcessor>();
157+
services.AddScoped<IToolSelector, ToolSelector>();
155158
services.AddScoped<IResultGenerator>(provider =>
156159
{
157160
var options = provider.GetRequiredService<IOptions<NLWebOptions>>();

src/NLWebNet/Models/NLWebOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,10 @@ public class NLWebOptions
7676
/// Multi-backend configuration options. When enabled, overrides single backend behavior.
7777
/// </summary>
7878
public MultiBackendOptions MultiBackend { get; set; } = new();
79+
80+
/// <summary>
81+
/// Whether to enable tool selection framework for routing queries to appropriate tools.
82+
/// When disabled, maintains existing behavior for backward compatibility.
83+
/// </summary>
84+
public bool ToolSelectionEnabled { get; set; } = false;
7985
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using NLWebNet.Models;
2+
3+
namespace NLWebNet.Services;
4+
5+
/// <summary>
6+
/// Interface for selecting appropriate tools based on query intent.
7+
/// </summary>
8+
public interface IToolSelector
9+
{
10+
/// <summary>
11+
/// Selects the appropriate tool based on the query intent and context.
12+
/// </summary>
13+
/// <param name="request">The NLWeb request containing the query and context</param>
14+
/// <param name="cancellationToken">Cancellation token for async operations</param>
15+
/// <returns>The selected tool name, or null if no specific tool is needed</returns>
16+
Task<string?> SelectToolAsync(NLWebRequest request, CancellationToken cancellationToken = default);
17+
18+
/// <summary>
19+
/// Determines if tool selection is needed for the given request.
20+
/// </summary>
21+
/// <param name="request">The NLWeb request to analyze</param>
22+
/// <returns>True if tool selection should be performed, false otherwise</returns>
23+
bool ShouldSelectTool(NLWebRequest request);
24+
}

src/NLWebNet/Services/QueryProcessor.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,32 @@ namespace NLWebNet.Services;
1111
public class QueryProcessor : IQueryProcessor
1212
{
1313
private readonly ILogger<QueryProcessor> _logger;
14+
private readonly IToolSelector? _toolSelector;
1415

15-
public QueryProcessor(ILogger<QueryProcessor> logger)
16+
public QueryProcessor(ILogger<QueryProcessor> logger, IToolSelector? toolSelector = null)
1617
{
1718
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
19+
_toolSelector = toolSelector;
1820
}
1921

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

27+
// Perform tool selection if available and enabled
28+
if (_toolSelector != null && _toolSelector.ShouldSelectTool(request))
29+
{
30+
var selectedTool = await _toolSelector.SelectToolAsync(request, cancellationToken);
31+
if (!string.IsNullOrEmpty(selectedTool))
32+
{
33+
_logger.LogDebug("Tool selection complete: {Tool} for request {QueryId}", selectedTool, request.QueryId);
34+
// Store the selected tool in the request for downstream processing
35+
// Note: This is a minimal implementation - the tool selection result could be used
36+
// by other components in the pipeline
37+
}
38+
}
39+
2540
// If decontextualized query is already provided, use it
2641
if (!string.IsNullOrEmpty(request.DecontextualizedQuery))
2742
{
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
using Microsoft.Extensions.Logging;
2+
using Microsoft.Extensions.Options;
3+
using NLWebNet.Models;
4+
5+
namespace NLWebNet.Services;
6+
7+
/// <summary>
8+
/// Implementation of tool selection logic that routes queries to appropriate tools based on intent.
9+
/// </summary>
10+
public class ToolSelector : IToolSelector
11+
{
12+
private readonly ILogger<ToolSelector> _logger;
13+
private readonly NLWebOptions _options;
14+
15+
/// <summary>
16+
/// Constants for tool names and associated keywords
17+
/// </summary>
18+
private static class ToolConstants
19+
{
20+
// Tool names
21+
public const string SearchTool = "search";
22+
public const string CompareTool = "compare";
23+
public const string DetailsTool = "details";
24+
public const string EnsembleTool = "ensemble";
25+
26+
// Keywords for each tool
27+
public static readonly string[] SearchKeywords = { "search", "find", "look for", "locate" };
28+
public static readonly string[] CompareKeywords = { "compare", "difference", "versus", "vs", "contrast" };
29+
public static readonly string[] DetailsKeywords = { "details", "information about", "tell me about", "describe" };
30+
public static readonly string[] EnsembleKeywords = { "recommend", "suggest", "what should", "ensemble", "set of" };
31+
}
32+
33+
public ToolSelector(ILogger<ToolSelector> logger, IOptions<NLWebOptions> options)
34+
{
35+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
36+
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
37+
}
38+
39+
/// <inheritdoc />
40+
public async Task<string?> SelectToolAsync(NLWebRequest request, CancellationToken cancellationToken = default)
41+
{
42+
if (!ShouldSelectTool(request))
43+
{
44+
_logger.LogDebug("Tool selection not needed for request {QueryId}", request.QueryId);
45+
return null;
46+
}
47+
48+
_logger.LogDebug("Selecting tool for query: {Query}", request.Query);
49+
50+
// Simple intent-based tool selection
51+
// In a full implementation, this would use more sophisticated intent analysis
52+
var selectedTool = await AnalyzeQueryIntentAsync(request.Query, cancellationToken);
53+
54+
_logger.LogDebug("Selected tool: {Tool} for query {QueryId}", selectedTool ?? "none", request.QueryId);
55+
return selectedTool;
56+
}
57+
58+
/// <inheritdoc />
59+
public bool ShouldSelectTool(NLWebRequest request)
60+
{
61+
// Don't perform tool selection if:
62+
// 1. Tool selection is disabled in configuration
63+
// 2. Generate mode is used (maintain existing behavior)
64+
// 3. Request already has a decontextualized query (already processed)
65+
66+
if (!_options.ToolSelectionEnabled)
67+
{
68+
return false;
69+
}
70+
71+
if (request.Mode == QueryMode.Generate)
72+
{
73+
_logger.LogDebug("Skipping tool selection for Generate mode to maintain existing behavior");
74+
return false;
75+
}
76+
77+
if (!string.IsNullOrEmpty(request.DecontextualizedQuery))
78+
{
79+
_logger.LogDebug("Skipping tool selection for request with decontextualized query");
80+
return false;
81+
}
82+
83+
return true;
84+
}
85+
86+
/// <summary>
87+
/// Analyzes the query intent to determine the appropriate tool.
88+
/// This is a simplified implementation - production would use more sophisticated NLP.
89+
/// </summary>
90+
private Task<string?> AnalyzeQueryIntentAsync(string query, CancellationToken cancellationToken)
91+
{
92+
var queryLower = query.ToLowerInvariant();
93+
94+
// Basic keyword-based intent detection
95+
// In production, this would use ML models or more sophisticated analysis
96+
97+
if (ContainsKeywords(queryLower, ToolConstants.SearchKeywords))
98+
{
99+
return Task.FromResult<string?>(ToolConstants.SearchTool);
100+
}
101+
102+
if (ContainsKeywords(queryLower, ToolConstants.CompareKeywords))
103+
{
104+
return Task.FromResult<string?>(ToolConstants.CompareTool);
105+
}
106+
107+
if (ContainsKeywords(queryLower, ToolConstants.DetailsKeywords))
108+
{
109+
return Task.FromResult<string?>(ToolConstants.DetailsTool);
110+
}
111+
112+
if (ContainsKeywords(queryLower, ToolConstants.EnsembleKeywords))
113+
{
114+
return Task.FromResult<string?>(ToolConstants.EnsembleTool);
115+
}
116+
117+
// Default to search tool for general queries
118+
return Task.FromResult<string?>(ToolConstants.SearchTool);
119+
}
120+
121+
private static bool ContainsKeywords(string text, string[] keywords)
122+
{
123+
return keywords.Any(keyword => text.Contains(keyword));
124+
}
125+
}

tests/NLWebNet.Tests/Services/QueryProcessorTests.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,80 @@ public async Task ProcessQueryAsync_ReturnsQuery()
121121
// Assert
122122
Assert.AreEqual("test query", result);
123123
}
124+
125+
[TestMethod]
126+
public async Task ProcessQueryAsync_WithToolSelector_CallsToolSelection()
127+
{
128+
// Arrange
129+
var toolSelectorLogger = new TestLogger<ToolSelector>();
130+
var nlWebOptions = new NLWebOptions { ToolSelectionEnabled = true };
131+
var options = Options.Create(nlWebOptions);
132+
var toolSelector = new ToolSelector(toolSelectorLogger, options);
133+
var queryProcessorWithToolSelector = new QueryProcessor(_logger, toolSelector);
134+
135+
var request = new NLWebRequest
136+
{
137+
Query = "search for something",
138+
Mode = QueryMode.List,
139+
QueryId = "test-query-id"
140+
};
141+
142+
// Act
143+
var result = await queryProcessorWithToolSelector.ProcessQueryAsync(request, CancellationToken.None);
144+
145+
// Assert
146+
Assert.AreEqual("search for something", result);
147+
// The tool selection should have been called but not affect the final result
148+
// since we're not changing the query processing behavior yet
149+
}
150+
151+
[TestMethod]
152+
public async Task ProcessQueryAsync_WithToolSelectorDisabled_DoesNotCallToolSelection()
153+
{
154+
// Arrange
155+
var toolSelectorLogger = new TestLogger<ToolSelector>();
156+
var nlWebOptions = new NLWebOptions { ToolSelectionEnabled = false };
157+
var options = Options.Create(nlWebOptions);
158+
var toolSelector = new ToolSelector(toolSelectorLogger, options);
159+
var queryProcessorWithToolSelector = new QueryProcessor(_logger, toolSelector);
160+
161+
var request = new NLWebRequest
162+
{
163+
Query = "search for something",
164+
Mode = QueryMode.List,
165+
QueryId = "test-query-id"
166+
};
167+
168+
// Act
169+
var result = await queryProcessorWithToolSelector.ProcessQueryAsync(request, CancellationToken.None);
170+
171+
// Assert
172+
Assert.AreEqual("search for something", result);
173+
// Tool selection should not have been called
174+
}
175+
176+
[TestMethod]
177+
public async Task ProcessQueryAsync_WithGenerateMode_SkipsToolSelection()
178+
{
179+
// Arrange
180+
var toolSelectorLogger = new TestLogger<ToolSelector>();
181+
var nlWebOptions = new NLWebOptions { ToolSelectionEnabled = true };
182+
var options = Options.Create(nlWebOptions);
183+
var toolSelector = new ToolSelector(toolSelectorLogger, options);
184+
var queryProcessorWithToolSelector = new QueryProcessor(_logger, toolSelector);
185+
186+
var request = new NLWebRequest
187+
{
188+
Query = "generate something",
189+
Mode = QueryMode.Generate,
190+
QueryId = "test-query-id"
191+
};
192+
193+
// Act
194+
var result = await queryProcessorWithToolSelector.ProcessQueryAsync(request, CancellationToken.None);
195+
196+
// Assert
197+
Assert.AreEqual("generate something", result);
198+
// Tool selection should have been skipped for Generate mode
199+
}
124200
}

0 commit comments

Comments
 (0)