feat: add extraction schema, sidebar nav, few-shot prompting, and prompt settings
Overhaul extraction pipeline with new TradeItem model, conversation flow, and dedicated extraction endpoint. Add sidebar navigation with NavMenu component and landing page. Introduce few-shot prompting service and tests. Add prompt settings and email upload specs. Update OpenSpec tooling with improved export-spec and extract-feature commands. Archive completed changes and export full specs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,27 +1,96 @@
|
||||
// ExtractionPluginTests.cs -- Tests for the extraction plugin's schema validation
|
||||
// and external API tool methods.
|
||||
//
|
||||
// ValidateSchema is tested directly (no external APIs needed).
|
||||
// LookupCounterparty, ValidateTrade, and ValidateCurrency are tested with
|
||||
// mocked HttpClients using MockHttpMessageHandler to simulate API responses.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ChatAgent.Api.Plugins;
|
||||
using ChatAgent.Api.Services;
|
||||
using ChatAgent.Shared.Models;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace ChatAgent.Api.Tests;
|
||||
|
||||
public class ExtractionPluginTests
|
||||
{
|
||||
private readonly ExtractionPlugin _plugin = new();
|
||||
// Helper to create an ExtractionPlugin with mocked HttpClients
|
||||
private static ExtractionPlugin CreatePluginWithMocks(
|
||||
HttpMessageHandler? counterpartyHandler = null,
|
||||
HttpMessageHandler? tradeHandler = null,
|
||||
HttpMessageHandler? currencyHandler = null)
|
||||
{
|
||||
var counterpartyClient = new CounterpartyApiClient(
|
||||
new HttpClient(counterpartyHandler ?? CreateOkHandler("[]"))
|
||||
{ BaseAddress = new Uri("http://test/") });
|
||||
|
||||
var tradeClient = new TradeApiClient(
|
||||
new HttpClient(tradeHandler ?? CreateOkHandler("{\"isValid\":true}"))
|
||||
{ BaseAddress = new Uri("http://test/") });
|
||||
|
||||
var currencyClient = new CurrencyApiClient(
|
||||
new HttpClient(currencyHandler ?? CreateOkHandler("{\"isValid\":true}"))
|
||||
{ BaseAddress = new Uri("http://test/") });
|
||||
|
||||
return new ExtractionPlugin(counterpartyClient, tradeClient, currencyClient);
|
||||
}
|
||||
|
||||
// Creates an HttpMessageHandler that returns a 200 OK with the given JSON body
|
||||
private static HttpMessageHandler CreateOkHandler(string jsonBody)
|
||||
{
|
||||
var mock = new Mock<HttpMessageHandler>();
|
||||
mock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(jsonBody, System.Text.Encoding.UTF8, "application/json")
|
||||
});
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
// Creates an HttpMessageHandler that throws HttpRequestException
|
||||
private static HttpMessageHandler CreateErrorHandler()
|
||||
{
|
||||
var mock = new Mock<HttpMessageHandler>();
|
||||
mock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Connection refused"));
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
// --- ValidateSchema tests ---
|
||||
|
||||
[Fact]
|
||||
public void ValidateExtractedFields_AllRequiredPresent_ReturnsValid()
|
||||
public void ValidateSchema_ValidResult_ReturnsValid()
|
||||
{
|
||||
var fields = new ExtractedFields
|
||||
var plugin = CreatePluginWithMocks();
|
||||
var extraction = new ExtractionResult
|
||||
{
|
||||
Client = "Acme Corp",
|
||||
Project = "Phase 2",
|
||||
Hours = 3,
|
||||
Rate = 150,
|
||||
Currency = "USD",
|
||||
Date = "2026-04-01"
|
||||
Items = new List<TradeItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Valuedate = "27/11/2025",
|
||||
Counterparty = "Assured Guaranty UK Limited",
|
||||
TradeId = 79353083,
|
||||
DisplayCcy = "GBP",
|
||||
Pv = 4562456.0,
|
||||
Breakclause = "N"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields));
|
||||
var resultJson = plugin.ValidateSchema(JsonSerializer.Serialize(extraction));
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
@@ -30,30 +99,66 @@ public class ExtractionPluginTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateExtractedFields_MissingRequired_ReturnsErrors()
|
||||
public void ValidateSchema_MissingFields_ReturnsErrors()
|
||||
{
|
||||
// Missing Client and Hours
|
||||
var fields = new ExtractedFields
|
||||
var plugin = CreatePluginWithMocks();
|
||||
// Missing counterparty and display_ccy
|
||||
var extraction = new ExtractionResult
|
||||
{
|
||||
Project = "Phase 2",
|
||||
Rate = 150,
|
||||
Currency = "USD",
|
||||
Date = "2026-04-01"
|
||||
Items = new List<TradeItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Valuedate = "27/11/2025",
|
||||
TradeId = 79353083,
|
||||
Pv = 4562456.0,
|
||||
Breakclause = "N"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields));
|
||||
var resultJson = plugin.ValidateSchema(JsonSerializer.Serialize(extraction));
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Client"));
|
||||
Assert.Contains(result.Errors, e => e.Contains("Hours"));
|
||||
Assert.Contains(result.Errors, e => e.Contains("counterparty"));
|
||||
Assert.Contains(result.Errors, e => e.Contains("display_ccy"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateExtractedFields_InvalidJson_ReturnsError()
|
||||
public void ValidateSchema_InvalidBreakclause_ReturnsError()
|
||||
{
|
||||
var resultJson = _plugin.ValidateExtractedFields("not valid json");
|
||||
var plugin = CreatePluginWithMocks();
|
||||
var extraction = new ExtractionResult
|
||||
{
|
||||
Items = new List<TradeItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Valuedate = "27/11/2025",
|
||||
Counterparty = "Test Corp",
|
||||
TradeId = 12345,
|
||||
DisplayCcy = "GBP",
|
||||
Pv = 100.0,
|
||||
Breakclause = "Maybe"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var resultJson = plugin.ValidateSchema(JsonSerializer.Serialize(extraction));
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("'Y' or 'N'"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSchema_InvalidJson_ReturnsError()
|
||||
{
|
||||
var plugin = CreatePluginWithMocks();
|
||||
var resultJson = plugin.ValidateSchema("not valid json");
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
@@ -62,45 +167,152 @@ public class ExtractionPluginTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateExtractedFields_ZeroHours_ReturnsError()
|
||||
public void ValidateSchema_EmptyItems_ReturnsError()
|
||||
{
|
||||
var fields = new ExtractedFields
|
||||
{
|
||||
Client = "Acme Corp",
|
||||
Project = "Phase 2",
|
||||
Hours = 0,
|
||||
Rate = 150,
|
||||
Currency = "USD",
|
||||
Date = "2026-04-01"
|
||||
};
|
||||
|
||||
var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields));
|
||||
var plugin = CreatePluginWithMocks();
|
||||
var resultJson = plugin.ValidateSchema("{\"items\":[]}");
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Hours"));
|
||||
Assert.Contains(result.Errors, e => e.Contains("no items"));
|
||||
}
|
||||
|
||||
// --- LookupCounterparty tests ---
|
||||
|
||||
[Fact]
|
||||
public async Task LookupCounterparty_SingleMatch_ReturnsValid()
|
||||
{
|
||||
var candidates = new List<CandidateMatch>
|
||||
{
|
||||
new() { Name = "Assured Guaranty UK Ltd", LegalEntity = "AG-UK-001" }
|
||||
};
|
||||
var handler = CreateOkHandler(JsonSerializer.Serialize(candidates));
|
||||
var plugin = CreatePluginWithMocks(counterpartyHandler: handler);
|
||||
|
||||
var resultJson = await plugin.LookupCounterparty("Assured Guaranty");
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.NotNull(result.Candidates);
|
||||
Assert.Single(result.Candidates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateExtractedFields_OptionalFieldsMissing_StillValid()
|
||||
public async Task LookupCounterparty_MultipleMatches_ReturnsInvalidWithCandidates()
|
||||
{
|
||||
// Description and PoNumber are optional
|
||||
var fields = new ExtractedFields
|
||||
var candidates = new List<CandidateMatch>
|
||||
{
|
||||
Client = "Acme Corp",
|
||||
Project = "Phase 2",
|
||||
Hours = 3,
|
||||
Rate = 150,
|
||||
Currency = "USD",
|
||||
Date = "2026-04-01"
|
||||
// Description and PoNumber intentionally omitted
|
||||
new() { Name = "Assured Guaranty UK Ltd", LegalEntity = "AG-UK-001" },
|
||||
new() { Name = "Assured Guaranty EU Ltd", LegalEntity = "AG-EU-002" }
|
||||
};
|
||||
var handler = CreateOkHandler(JsonSerializer.Serialize(candidates));
|
||||
var plugin = CreatePluginWithMocks(counterpartyHandler: handler);
|
||||
|
||||
var resultJson = _plugin.ValidateExtractedFields(JsonSerializer.Serialize(fields));
|
||||
var resultJson = await plugin.LookupCounterparty("Assured Guaranty");
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.NotNull(result.Candidates);
|
||||
Assert.Equal(2, result.Candidates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LookupCounterparty_ApiError_ReturnsErrorMessage()
|
||||
{
|
||||
var plugin = CreatePluginWithMocks(counterpartyHandler: CreateErrorHandler());
|
||||
|
||||
var resultJson = await plugin.LookupCounterparty("Test");
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Counterparty API unavailable"));
|
||||
}
|
||||
|
||||
// --- ValidateTrade tests ---
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateTrade_ValidId_ReturnsValid()
|
||||
{
|
||||
var handler = CreateOkHandler("{\"isValid\":true}");
|
||||
var plugin = CreatePluginWithMocks(tradeHandler: handler);
|
||||
|
||||
var resultJson = await plugin.ValidateTrade(79353083);
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateTrade_InvalidId_ReturnsError()
|
||||
{
|
||||
var handler = CreateOkHandler("{\"isValid\":false,\"message\":\"Trade not found\"}");
|
||||
var plugin = CreatePluginWithMocks(tradeHandler: handler);
|
||||
|
||||
var resultJson = await plugin.ValidateTrade(99999);
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Trade not found"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateTrade_ApiError_ReturnsErrorMessage()
|
||||
{
|
||||
var plugin = CreatePluginWithMocks(tradeHandler: CreateErrorHandler());
|
||||
|
||||
var resultJson = await plugin.ValidateTrade(12345);
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Trade validation API unavailable"));
|
||||
}
|
||||
|
||||
// --- ValidateCurrency tests ---
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateCurrency_ValidCode_ReturnsValid()
|
||||
{
|
||||
var handler = CreateOkHandler("{\"isValid\":true}");
|
||||
var plugin = CreatePluginWithMocks(currencyHandler: handler);
|
||||
|
||||
var resultJson = await plugin.ValidateCurrency("GBP");
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateCurrency_InvalidCode_ReturnsError()
|
||||
{
|
||||
var handler = CreateOkHandler("{\"isValid\":false,\"message\":\"Unknown currency\",\"suggestions\":[\"GBP\",\"GEL\"]}");
|
||||
var plugin = CreatePluginWithMocks(currencyHandler: handler);
|
||||
|
||||
var resultJson = await plugin.ValidateCurrency("GBX");
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("GBP"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateCurrency_ApiError_ReturnsErrorMessage()
|
||||
{
|
||||
var plugin = CreatePluginWithMocks(currencyHandler: CreateErrorHandler());
|
||||
|
||||
var resultJson = await plugin.ValidateCurrency("USD");
|
||||
var result = JsonSerializer.Deserialize<ValidationResult>(resultJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Currency validation API unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
75
tests/ChatAgent.Api.Tests/FewShotServiceTests.cs
Normal file
75
tests/ChatAgent.Api.Tests/FewShotServiceTests.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
// FewShotServiceTests.cs -- Tests for the FewShotService that loads few-shot
|
||||
// examples from disk and assembles ChatHistory prefixes.
|
||||
|
||||
using ChatAgent.Api.Services;
|
||||
using ChatAgent.Shared.Models;
|
||||
|
||||
namespace ChatAgent.Api.Tests;
|
||||
|
||||
public class FewShotServiceTests
|
||||
{
|
||||
// Path to the examples folder from the test bin directory.
|
||||
// WebApplicationFactory resolves content root to the API project,
|
||||
// but for direct unit tests we resolve from the test assembly location.
|
||||
private static string GetExamplesPath()
|
||||
{
|
||||
// Navigate from test bin/Debug/net9.0/ up to the repo root, then into examples/
|
||||
var testDir = AppContext.BaseDirectory;
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", ".."));
|
||||
return Path.Combine(repoRoot, "examples", "extraction");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_LoadsInstructionTemplateAndExamples()
|
||||
{
|
||||
var service = new FewShotService(GetExamplesPath());
|
||||
|
||||
// Should have: 1 system message + 3 examples × 2 messages each = 7 messages
|
||||
Assert.Equal(7, service.PrefixMessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloneWithEmail_AppendsEmailAsUserMessage()
|
||||
{
|
||||
var service = new FewShotService(GetExamplesPath());
|
||||
|
||||
var history = service.CloneWithEmail("<html><body>Test email</body></html>");
|
||||
|
||||
// Prefix (7) + 1 email user message = 8
|
||||
Assert.Equal(8, history.Count);
|
||||
// Last message should be the email
|
||||
Assert.Equal("<html><body>Test email</body></html>", history.Last().Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloneWithEmailAndMessages_AppendsEmailAndFollowUps()
|
||||
{
|
||||
var service = new FewShotService(GetExamplesPath());
|
||||
|
||||
var followUp = new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "assistant", Content = "Which counterparty?" },
|
||||
new() { Role = "user", Content = "Option 1" }
|
||||
};
|
||||
|
||||
var history = service.CloneWithEmailAndMessages("<html>email</html>", followUp);
|
||||
|
||||
// Prefix (7) + 1 email + 2 follow-up = 10
|
||||
Assert.Equal(10, history.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloneWithEmail_DoesNotMutatePrefix()
|
||||
{
|
||||
var service = new FewShotService(GetExamplesPath());
|
||||
|
||||
// Clone twice — second clone should not include the first email
|
||||
var history1 = service.CloneWithEmail("email 1");
|
||||
var history2 = service.CloneWithEmail("email 2");
|
||||
|
||||
Assert.Equal(8, history1.Count);
|
||||
Assert.Equal(8, history2.Count);
|
||||
Assert.Equal("email 1", history1.Last().Content);
|
||||
Assert.Equal("email 2", history2.Last().Content);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user