using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.Assets; using FinlyticCore.Models.Assets; using FinlyticCore.Services; using FinlyticNews.Util; namespace FinlyticNews.Services; /// /// Service interface for validating candidate asset matches against FinlyticAssets metadata and sector clustering. /// public interface IAssetValidationService { /// /// Validates and filters candidate asset matches using FinlyticAssets metadata, sector clustering, and occurrence rules. /// Task> ValidateCandidateAssetsAsync( List candidates, string title, string content, CancellationToken cancellationToken = default); } /// /// Heuristic asset validation engine verifying asset existence, title priority, mention density, and sector coherence. /// public class AssetValidationService : IAssetValidationService { private readonly IFinlyticLogger _finlyticLogger; private readonly NewsMqttClient _mqttClient; private readonly ISettingsService _settingsService; public AssetValidationService( IFinlyticLogger finlyticLogger, NewsMqttClient mqttClient, ISettingsService settingsService) { _finlyticLogger = finlyticLogger; _mqttClient = mqttClient; _settingsService = settingsService; } public async Task> ValidateCandidateAssetsAsync( List candidates, string title, string content, CancellationToken cancellationToken = default) { if (candidates.Count == 0) return []; var verifiedAssets = new List(); var assetDetails = new Dictionary(StringComparer.OrdinalIgnoreCase); // 1. Fetch Asset Details from FinlyticAssets via MQTT RPC foreach (var candidate in candidates) { try { var req = new GetValidAssetRequest(candidate.Isin); var assets = await _mqttClient.RequestAsync>("services/request/assets_Get", req); if (assets != null && assets.Count > 0) { var match = assets.FirstOrDefault(a => a.Isin.Equals(candidate.Isin, StringComparison.OrdinalIgnoreCase)); if (match != null) { assetDetails[candidate.Isin] = match; } } } catch (Exception ex) { await _finlyticLogger.LogWarningAsync(SettingKeys.MatcherChannel, ex, "[AssetValidationService] Failed to query FinlyticAssets for ISIN {Isin}. Falling back to index data.", candidate.Isin); } } // 2. Tag & Sector Frequency Analysis (Clustering) var sectorCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (_, dto) in assetDetails) { if (dto.Tags != null) { foreach (var tag in dto.Tags) { if (tag.Type.Equals("sector", StringComparison.OrdinalIgnoreCase) || tag.Type.Equals("industry", StringComparison.OrdinalIgnoreCase)) { sectorCounts[tag.Name] = sectorCounts.GetValueOrDefault(tag.Name, 0) + 1; } } } } bool hasDominantSector = sectorCounts.Values.Any(count => count >= 2); string? dominantSector = sectorCounts.OrderByDescending(kv => kv.Value).FirstOrDefault().Key; // 3. Evaluate Each Candidate foreach (var candidate in candidates) { // Rule A: Direct Title Match or URL Match -> Guaranteed Valid if (candidate.InTitle || candidate.InUrl) { verifiedAssets.Add(candidate); continue; } // Rule B: Multiple Mentions in Article Body if (candidate.MentionCount >= 2) { verifiedAssets.Add(candidate); continue; } // Rule C: Single Mention Validation with Sector Clustering if (assetDetails.TryGetValue(candidate.Isin, out var dto)) { bool matchesDominantSector = false; if (hasDominantSector && dto.Tags != null) { matchesDominantSector = dto.Tags.Any(t => t.Name.Equals(dominantSector, StringComparison.OrdinalIgnoreCase)); } if (matchesDominantSector) { // Reinforced by cluster verifiedAssets.Add(candidate); } else if (candidate.InitialConfidence >= 0.85) { // High-confidence exact unique name match verifiedAssets.Add(candidate); } else { await _finlyticLogger.LogDebugAsync(SettingKeys.MatcherChannel, "[AssetValidationService] Pruned low-confidence outlier candidate: {Name} ({Isin})", candidate.Name, candidate.Isin); } } else { // If not found in FinlyticAssets, discard await _finlyticLogger.LogDebugAsync(SettingKeys.MatcherChannel, "[AssetValidationService] Discarded candidate {Isin} as it does not exist in FinlyticAssets.", candidate.Isin); } } return verifiedAssets; } }