using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.Simulation;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Services;
using FinlyticEngine.Services.Scoring;
using FinlyticEngine.Settings;
using Microsoft.Extensions.Configuration;
namespace FinlyticEngine.Services.Ai;
public class AiReasoningGateService : IAiReasoningGateService
{
///
/// Self-contained fallback framing sent as part of every request's instructions field: role, the
/// four things the validator must actually weigh, the exact expected JSON response schema, and a
/// fail-closed default. Kept here (not only in n8n's own system prompt) so validation still behaves
/// sensibly even if n8n's system prompt is ever left empty/misconfigured - defense in depth, not reliance
/// on a single external configuration surface.
///
private const string BaseInstructions =
"Du bist der Senior Risk & Trade Validator für Finlytic, ein automatisiertes Trading-System. " +
"Bewerte, ob das folgende technische Setup als Trade-Vorschlag freigegeben werden soll. Prüfe " +
"insbesondere: (1) Widersprechen sich technisches Signal, Sentiment-Lage und Fundamentaldaten? " +
"(2) Deutet eine aktive Earnings- oder Dividenden-Sperre auf einen bevorstehenden, schwer " +
"kalkulierbaren Kurssprung hin? (3) Was sagt die Backtest-Historie (falls vorhanden) über die " +
"Zuverlässigkeit dieser Strategie für genau dieses Asset? (4) Passt das Risk/Reward-Verhältnis zum " +
"aktuellen Markt-Regime? Antworte AUSSCHLIESSLICH mit einem einzelnen JSON-Objekt exakt in diesem " +
"Schema, ohne Text davor oder danach: {\"isApproved\": bool, \"confidence\": number|null (0.0-1.0), " +
"\"thesisSummary\": string, \"invalidationReason\": string, \"keyCatalysts\": string[], " +
"\"identifiedRisks\": string[]}. Sei im Zweifel eher ablehnend (fail-closed) - ein verpasster Trade " +
"ist günstiger als ein falscher.";
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger _finlyticLogger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public AiReasoningGateService(
HttpClient httpClient,
IConfiguration configuration,
ISettingsService settingsService,
IFinlyticLogger finlyticLogger)
{
_httpClient = httpClient;
_configuration = configuration;
_settingsService = settingsService;
_finlyticLogger = finlyticLogger;
}
///
public async Task ValidateOpportunityAsync(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
ScoringResult score,
StrategyAssetReliabilityDto? reliability = null,
CancellationToken cancellationToken = default)
{
var minCompositeScore = await _settingsService.GetSettingAsync(EngineSettingKeys.MinCompositeScore, cancellationToken);
var enableAi = await _settingsService.GetSettingAsync(EngineSettingKeys.EnableAiValidation, cancellationToken);
if (!enableAi)
{
// Deterministic Fast-Pass: rein regelbasiert, keine KI beteiligt.
return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore,
"[Regelbasiert] AI-Validierungs-Gate ist deaktiviert (Fast-Pass Modus).");
}
var webhookUrl = _configuration["Ai:N8nValidationWebhookUrl"] ?? "https://n8n.kleidukos.me/webhook/trade-validation";
var timeoutSeconds = await _settingsService.GetSettingAsync(EngineSettingKeys.AiValidationTimeoutSeconds, cancellationToken);
try
{
var payload = BuildRequestPayload(setup, sentiment, fundamentals, score, reliability);
var jsonContent = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
await _finlyticLogger.LogInfoAsync(EngineSettingKeys.AiValidationChannel,
"[AiReasoningGate] Sending AI validation request for ISIN {Isin} to {Url}", setup.Isin, webhookUrl);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)));
var response = await _httpClient.PostAsync(webhookUrl, jsonContent, cts.Token);
if (response.IsSuccessStatusCode)
{
var responseJson = await response.Content.ReadAsStringAsync(cts.Token);
var aiResult = ParseN8nValidationResponse(responseJson);
// Defense in depth, independent of the specific mapping above: System.Text.Json does not
// throw when a JSON object's property names match none of AiValidationResultDto's - it just
// builds the record from parameter defaults (ThesisSummary=null, IsApproved=false, ...), which
// then LOOKS like a real, successfully-parsed AI result even though nothing was actually
// extracted. That previously reached SaveChangesAsync with a null ThesisSummary and crashed on
// the NOT NULL constraint on engine_evaluation_snapshots.AiThesisSummary. Treat a result with
// no usable thesis exactly like "no usable JSON at all", regardless of why parsing came up
// empty (missing field, webhook contract drift, malformed nesting, ...).
if (aiResult != null && !string.IsNullOrWhiteSpace(aiResult.ThesisSummary))
{
// Herkunft ist immer echte KI, unabhängig davon, ob der Webhook das Feld selbst setzt.
aiResult = aiResult with { Source = ValidationSource.Ai };
await _finlyticLogger.LogInfoAsync(EngineSettingKeys.AiValidationChannel,
"[AiReasoningGate] AI validation result for {Isin}: Approved={Approved}, Confidence={Conf}",
setup.Isin, aiResult.IsApproved, aiResult.Confidence?.ToString("F2") ?? "n/a");
return aiResult;
}
await _finlyticLogger.LogWarningAsync(EngineSettingKeys.AiValidationChannel,
"[AiReasoningGate] Webhook antwortete mit Status {Status} für ISIN {Isin}, lieferte aber kein verwertbares JSON-Ergebnis (RawResponse={RawResponse}). Regelbasierter Fallback.",
response.StatusCode, setup.Isin, responseJson);
return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore,
"[Regelbasiert] KI-Webhook antwortete erfolgreich, aber ohne verwertbares Ergebnis - automatische Freigabe basierend auf technischer und Sentiment-Confluence.");
}
// Webhook wurde erreicht, hat die Anfrage aber explizit mit einem Fehlerstatus abgelehnt.
await _finlyticLogger.LogWarningAsync(EngineSettingKeys.AiValidationChannel,
"[AiReasoningGate] Webhook lehnte Validierungsanfrage für ISIN {Isin} mit Status {Status} ab. Regelbasierter Fallback.",
setup.Isin, response.StatusCode);
return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore,
$"[Regelbasiert] KI-Webhook hat die Anfrage mit Status {(int)response.StatusCode} abgelehnt - automatische Freigabe basierend auf technischer und Sentiment-Confluence.");
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
if (cancellationToken.IsCancellationRequested)
{
// Echter Abbruch durch den Aufrufer, kein Webhook-Problem - nicht als Fachfehler verschlucken.
throw;
}
// Webhook war innerhalb des Timeouts nicht erreichbar (Netzwerkfehler/Timeout).
await _finlyticLogger.LogWarningAsync(EngineSettingKeys.AiValidationChannel, ex,
"[AiReasoningGate] KI-Webhook für ISIN {Isin} nicht erreichbar (Timeout/Netzwerkfehler). Regelbasierter Fallback.", setup.Isin);
return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore,
"[Regelbasiert] KI-Webhook war nicht erreichbar (Timeout/Netzwerkfehler) - automatische Freigabe basierend auf technischer und Sentiment-Confluence.");
}
}
///
/// Assembles the full, richly-contextualized request payload sent to n8n - every signal already computed
/// elsewhere in the evaluation pipeline (sub-scores, detected patterns/indicators, market regime, why the
/// ISIN was being watched, backtest reliability, raw fundamentals/events) rather than only the bare
/// composite score the previous payload sent (Rules.md §4: every field here is a real, already-computed
/// value - nothing is invented for the AI's benefit).
///
private static N8nValidationRequestPayload BuildRequestPayload(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
ScoringResult score,
StrategyAssetReliabilityDto? reliability)
{
var takeProfit1 = setup.ExitPlan.TakeProfitStages.Count > 0
? setup.ExitPlan.TakeProfitStages[0].TargetPrice
: setup.EntryPrice * 1.05m;
var technicalSetup = new N8nTechnicalSetupSection(
Strategy: setup.StrategyKey,
StrategyName: setup.StrategyName,
Direction: setup.Direction.ToString(),
Entry: setup.EntryPrice,
StopLoss: setup.InvalidationPrice,
TakeProfit1: takeProfit1,
RiskRewardRatio: setup.EstimatedRiskRewardRatio,
QualityScore: setup.QualityScore,
Rationale: setup.TechnicalRationale,
MarketRegime: setup.Regime?.ToString(),
TriggeringPatterns: setup.TriggeringPatterns.ConvertAll(p =>
new N8nPatternSection(p.Type.ToString(), p.Bias.ToString(), p.QualityScore, p.Description)),
IndicatorSnapshot: setup.IndicatorSnapshot
);
var watchlistContext = setup.UniverseSource.HasValue && setup.UniverseEnteredAtUtc.HasValue
? new N8nWatchlistContextSection(setup.UniverseSource.Value.ToString(), setup.UniverseEnteredAtUtc.Value)
: null;
var sentimentSection = new N8nSentimentSection(
Label: sentiment?.CurrentSummary?.SentimentLabel ?? "NEUTRAL",
WeightedScore: sentiment?.CurrentSummary?.CompoundScore ?? 0.0,
Trend: sentiment?.CurrentSummary?.Trend ?? "STABLE",
LatestHighlight: sentiment?.CurrentSummary?.KeyHighlight ?? "Keine aktuellen News-Highlights"
);
var fund = fundamentals?.Fundamentals;
decimal? operatingMarginPercent = fund?.OperatingIncome.HasValue == true && fund.TotalRevenue is > 0
? Math.Round(fund.OperatingIncome!.Value / fund.TotalRevenue!.Value * 100m, 2)
: null;
var fundamentalsSection = new N8nFundamentalsSection(
ForwardPe: fund?.ForwardPe,
OperatingMarginPercent: operatingMarginPercent,
RevenueGrowthYoYRatio: fund?.RevenueGrowthYoY,
ReturnOnEquityRatio: fund?.ReturnOnEquity,
DebtToEquity: fund?.DebtToEquity,
FreeCashFlow: fund?.FreeCashFlow,
ConsensusRating: fund?.ConsensusRating,
PriceTargetMean: fund?.PriceTargetMean,
ShortPercentOfFloatRatio: fund?.ShortPercentOfFloat,
DaysToEarnings: score.DaysToNextEarnings,
PassedEarningsLockout: score.PassedEarningsLockout,
DaysToNextExDividend: score.DaysToNextExDividend,
PassedDividendGate: score.PassedDividendGate
);
var reliabilitySection = reliability == null
? null
: new N8nReliabilitySection(
ReliabilityScore: reliability.ReliabilityScore,
WinRatePercent: reliability.WinRatePercent,
ProfitFactor: reliability.ProfitFactor,
SampleTradeCount: reliability.SampleTradeCount,
IsStrategyApprovedForAsset: reliability.IsStrategyApprovedForAsset,
RecommendedAction: reliability.RecommendedAction);
var scoreBreakdown = new N8nScoreBreakdownSection(
CompositeScore: score.CompositeScore,
TechnicalScore: score.TechnicalScore,
SentimentScore: score.SentimentScore,
FundamentalScore: score.FundamentalScore,
ReliabilityBonus: score.ReliabilityBonus
);
return new N8nValidationRequestPayload(
Instructions: BaseInstructions,
Asset: new N8nAssetSection(setup.Isin, setup.Symbol, setup.CurrentPrice),
TechnicalSetup: technicalSetup,
WatchlistContext: watchlistContext,
Sentiment: sentimentSection,
Fundamentals: fundamentalsSection,
Reliability: reliabilitySection,
ScoreBreakdown: scoreBreakdown
);
}
///
/// Deserialisiert die Antwort des n8n-Validierungs-Webhooks. Erwartet dieselbe Feldbenennung wie
/// selbst (camelCase isApproved/thesisSummary/...) statt
/// eines separaten, undokumentierten Vokabulars - und kann sowohl als einzelnes JSON-Objekt als auch -
/// wie vom n8n "Respond to Webhook"-Knoten bei "All Incoming Items" üblich - als Array mit einem Element
/// eintreffen. Nur real im Payload vorhandene Felder fließen ein (Rules.md §4).
///
///
/// , wenn der Payload syntaktisch kein JSON-Objekt (bzw. Array mit einem Objekt als
/// erstem Element) ist, oder wenn isApproved/thesisSummary - die zwei Felder, ohne die kein
/// verwertbares Ergebnis vorliegt - fehlen.
///
private static AiValidationResultDto? ParseN8nValidationResponse(string responseJson)
{
JsonElement root;
try
{
root = JsonSerializer.Deserialize(responseJson, JsonOptions);
}
catch (JsonException)
{
return null;
}
var element = root.ValueKind switch
{
JsonValueKind.Array => root.GetArrayLength() > 0 ? root[0] : (JsonElement?)null,
JsonValueKind.Object => root,
_ => null
};
if (element is not { ValueKind: JsonValueKind.Object } obj)
{
return null;
}
N8nValidationResponsePayload? payload;
try
{
payload = obj.Deserialize(JsonOptions);
}
catch (JsonException)
{
return null;
}
if (payload?.IsApproved is null || string.IsNullOrWhiteSpace(payload.ThesisSummary))
{
// Nothing usable: either malformed JSON, or the validator didn't answer the two things that
// matter most (a clear yes/no and a reason). The caller treats this identically to "no usable
// JSON at all" (Rules.md §4: a partially-empty response must never masquerade as a real verdict).
return null;
}
return new AiValidationResultDto(
IsApproved: payload.IsApproved.Value,
Confidence: payload.Confidence,
Source: ValidationSource.Ai,
ThesisSummary: payload.ThesisSummary,
InvalidationReason: payload.InvalidationReason ?? payload.ThesisSummary,
KeyCatalysts: payload.KeyCatalysts ?? new List(),
IdentifiedRisks: payload.IdentifiedRisks ?? new List()
);
}
///
/// Erstellt eine regelbasierte Freigabe-/Ablehnungsentscheidung, wenn keine echte KI-Bewertung
/// vorliegt (Gate deaktiviert, Webhook nicht erreichbar oder Webhook liefert kein verwertbares
/// Ergebnis). Die Entscheidung selbst (,
/// und )
/// ist legitime regelbasierte Geschäftslogik, aber es wird bewusst KEINE Konfidenz erfunden (Rules.md §4)
/// und die Herkunft wird explizit als markiert, damit
/// Frontend/Logs sie nicht mit einer echten KI-These verwechseln.
///
///
/// - reused here instead of a second, independently
/// hardcoded threshold, so the rule-based fallback's bar for approval always matches the real score gate
/// the AI-backed path is gated by (previously duplicated as a separate literal 70.0m).
///
private static AiValidationResultDto CreateRuleBasedResult(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
ScoringResult score,
decimal minCompositeScore,
string summary)
{
var catalysts = new List
{
$"Technisches Signal '{setup.StrategyName}' mit Quality-Score {setup.QualityScore:F1}",
sentiment?.CurrentSummary != null ? $"Sentiment: {sentiment.CurrentSummary.SentimentLabel} (Trend: {sentiment.CurrentSummary.Trend})" : "Neutrales Marktumfeld"
};
var risks = new List
{
$"Invalidierung bei {setup.InvalidationPrice:F2} €",
score.DaysToNextEarnings.HasValue ? $"Nächste Quartalszahlen in {score.DaysToNextEarnings.Value} Tagen" : "Allgemeine Marktvolatilität"
};
if (score.DaysToNextExDividend.HasValue)
{
risks.Add($"Nächster Ex-Dividenden-Tag in {score.DaysToNextExDividend.Value} Tag(en)");
}
return new AiValidationResultDto(
IsApproved: score.CompositeScore >= minCompositeScore && score.PassedEarningsLockout && score.PassedDividendGate,
Confidence: null,
Source: ValidationSource.RuleBased,
ThesisSummary: summary,
InvalidationReason: $"Schlusskurs unter {setup.InvalidationPrice:F2} € invalidiert das Setup.",
KeyCatalysts: catalysts,
IdentifiedRisks: risks
);
}
}