feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using FinlyticEngine.Services.Ai;
|
||||
using Xunit;
|
||||
|
||||
namespace FinlyticEngine.Tests.Services.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for the n8n validation-webhook response parser. The contract was redesigned to match
|
||||
/// <see cref="AiValidationResultDto"/>'s own field names 1:1 (camelCase <c>isApproved</c>/<c>thesisSummary</c>/
|
||||
/// <c>invalidationReason</c>/<c>keyCatalysts</c>/<c>identifiedRisks</c>) instead of a separate, undocumented
|
||||
/// vocabulary (<c>status</c>/<c>action_recommendation</c>/nested <c>raw_validation_result</c>) that no prompt
|
||||
/// ever actually specified. System.Text.Json does not throw on a field-name mismatch - it silently builds a
|
||||
/// record from parameter defaults, which then LOOKS like a real, successfully-parsed AI result even though
|
||||
/// nothing was extracted (this previously reached <c>SaveChangesAsync</c> with a null <c>ThesisSummary</c> and
|
||||
/// crashed on the NOT NULL constraint on <c>engine_evaluation_snapshots.AiThesisSummary</c>) - hence the
|
||||
/// explicit "missing isApproved/thesisSummary -> null" guard these tests exercise. Tests the private parser
|
||||
/// directly via reflection since it is an internal implementation detail of the service, not part of its
|
||||
/// public contract.
|
||||
/// </summary>
|
||||
public class AiReasoningGateServiceTests
|
||||
{
|
||||
private static AiValidationResultDto? Parse(string json)
|
||||
{
|
||||
var method = typeof(AiReasoningGateService).GetMethod(
|
||||
"ParseN8nValidationResponse", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.NotNull(method);
|
||||
return (AiValidationResultDto?)method!.Invoke(null, new object[] { json });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseN8nValidationResponse_RejectedPayload_ExtractsThesisAndRisksWithoutNulls()
|
||||
{
|
||||
// n8n's "Respond to Webhook" node commonly wraps a single result in a one-element array ("All
|
||||
// Incoming Items") - the parser must unwrap that transparently.
|
||||
const string payload = """
|
||||
[
|
||||
{
|
||||
"isApproved": false,
|
||||
"confidence": 0.72,
|
||||
"thesisSummary": "Diskrepanz zwischen technischem Volatilitäts-Breakout und fehlender fundamentaler/sentimentaler Bestätigung.",
|
||||
"invalidationReason": "Ausbruch ohne Nachrichtenkatalysator - hohe Wahrscheinlichkeit eines Fehlausbruchs.",
|
||||
"keyCatalysts": [],
|
||||
"identifiedRisks": [
|
||||
"Der Ausbruch findet in einem nachrichtenarmen Umfeld statt.",
|
||||
"Risikostufe laut Validator: MEDIUM"
|
||||
]
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var result = Parse(payload);
|
||||
|
||||
Assert.NotNull(result);
|
||||
// The core regression: ThesisSummary must never be null/empty for a parseable response — this is
|
||||
// exactly the value that used to violate the NOT NULL constraint.
|
||||
Assert.False(string.IsNullOrWhiteSpace(result!.ThesisSummary));
|
||||
Assert.Contains("Diskrepanz", result.ThesisSummary);
|
||||
Assert.False(result.IsApproved);
|
||||
Assert.Equal(0.72m, result.Confidence);
|
||||
Assert.Equal(ValidationSource.Ai, result.Source);
|
||||
Assert.Contains(result.IdentifiedRisks, r => r.Contains("nachrichtenarmen"));
|
||||
Assert.Contains(result.IdentifiedRisks, r => r.Contains("MEDIUM"));
|
||||
Assert.Empty(result.KeyCatalysts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseN8nValidationResponse_ApprovedNoConfidence_IsApprovedTrueAndConfidenceNull()
|
||||
{
|
||||
const string payload = """{"isApproved": true, "thesisSummary": "Alles im gruenen Bereich."}""";
|
||||
|
||||
var result = Parse(payload);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.IsApproved);
|
||||
// No numeric confidence was sent - none must be invented (Rules.md §4).
|
||||
Assert.Null(result.Confidence);
|
||||
// Not supplied by the webhook in this payload - must default to empty, not fabricated.
|
||||
Assert.Empty(result.KeyCatalysts);
|
||||
Assert.Empty(result.IdentifiedRisks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseN8nValidationResponse_MissingIsApproved_ReturnsNull()
|
||||
{
|
||||
// A validator that supplies a thesis but never actually says yes/no is not a usable verdict - fail
|
||||
// closed rather than defaulting IsApproved to false while looking like a fully-parsed result.
|
||||
const string payload = """{"thesisSummary": "Setup sieht grundsaetzlich brauchbar aus."}""";
|
||||
|
||||
var result = Parse(payload);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseN8nValidationResponse_MissingThesisSummary_ReturnsNull()
|
||||
{
|
||||
const string payload = """{"isApproved": true}""";
|
||||
|
||||
var result = Parse(payload);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseN8nValidationResponse_CompletelyUnrelatedSchema_ReturnsNull()
|
||||
{
|
||||
// Simulates any future webhook contract drift that shares zero field names with what this parser
|
||||
// knows about. Must degrade to "no usable result" (null), never to a garbage non-null object with
|
||||
// an empty ThesisSummary - the caller's guard only protects against the latter if this returns null
|
||||
// or a result whose ThesisSummary is blank.
|
||||
const string payload = """{"foo": "bar", "baz": 42}""";
|
||||
|
||||
var result = Parse(payload);
|
||||
|
||||
Assert.True(result is null || string.IsNullOrWhiteSpace(result.ThesisSummary));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseN8nValidationResponse_NotJson_ReturnsNull()
|
||||
{
|
||||
var result = Parse("this is not json at all");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseN8nValidationResponse_EmptyArray_ReturnsNull()
|
||||
{
|
||||
var result = Parse("[]");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user