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