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:
local
2026-04-06 23:39:23 +01:00
parent 7a5c22593a
commit 5b027eb0db
83 changed files with 4242 additions and 296 deletions

View File

@@ -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"));
}
}