using FrameworkDAL.CustomCode.ContentDiscovery; using FrameworkDAL.DTO.ContentDiscovery; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.GB5Constant; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; namespace FrameworkBLL.ContentDiscovery; // Report/Menu Discovery Layer — see IContentDiscoveryCheckBLL / the discoverability plan for why // this is a distinct catalog+scoring engine from AMP's ApiRequestDuplicateCheckBLL, not a reuse of // AMP's tables. The scoring/persist/review shape is deliberately copied from that proven design. public class ContentDiscoveryCheckBLL( IContentDiscoveryCheckDAL _dal, AutoNumber _autoNumber, EventLogPublish _eventLog, ILogger _logger ) : IContentDiscoveryCheckBLL { private static readonly HashSet StopWords = ["the", "a", "an", "to", "of", "in", "for", "and", "or", "by", "with", "get", "set", "list", "report", "view"]; public async Task<(int Count, List TopMatches)> RunDiscoveryCheckAsync( string proposedTitle, string? proposedKeywords, int proposedModuleId, byte kind, LoginDTO login, CancellationToken ct) { if (string.IsNullOrWhiteSpace(proposedTitle)) throw new ArgumentException("ProposedTitle is required."); try { GB5Trace.Step("content-discovery-score", new { proposedTitle, proposedModuleId, kind }); var keywords = ExtractKeywords(proposedKeywords ?? proposedTitle); var (kw1, kw2, kw3, kw4, kw5) = PadKeywords(keywords); var matches = await _dal.FindSimilarityInReportsAsync( proposedTitle, proposedModuleId, kw1, kw2, kw3, kw4, kw5, login, ct).ConfigureAwait(false); if (matches.Count == 0) { _logger.LogInformation("RunDiscoveryCheck found no matches for {Title}", proposedTitle); return (0, []); } var autoNumberDto = await _autoNumber.GetNumberAsync(matches.Count, "MCONTENTDISCOVERYCHECK", login) .ConfigureAwait(false); int nextId = autoNumberDto.StartNumber; var savedChecks = new List(); var saveTasks = new List(); foreach (var match in matches) { ct.ThrowIfCancellationRequested(); var viewCount = await _dal.GetViewCountAsync(match.MatchedReportId, login, ct).ConfigureAwait(false); var (suggestionType, suggestedChange) = DetermineSuggestion(match, viewCount); var dto = BuildSaveDto(nextId++, proposedTitle, proposedKeywords, proposedModuleId, kind, match, suggestionType, suggestedChange, login); savedChecks.Add(ToReadDto(dto)); saveTasks.Add(_dal.SaveDiscoveryCheckAsync(dto, login, ct)); } await Task.WhenAll(saveTasks).ConfigureAwait(false); GB5Trace.Step("content-discovery-event-publish", new { EventTypes = Constant.EventTypeConstant.SAVEDOCUMENTSET }); try { await _eventLog.PublishEventLogAsync( "Content Discovery Check Completed", new { proposedTitle, MatchesFound = savedChecks.Count }, Constant.EventTypeConstant.SAVEDOCUMENTSET, 0, login).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Event publish failed for content discovery check '{Title}'", proposedTitle); } return (savedChecks.Count, savedChecks.OrderByDescending(x => x.OverallScore).Take(5).ToList()); } catch (Exception ex) { GB5Trace.MarkFailed("content-discovery-check-failed", ex); _logger.LogError(ex, "RunDiscoveryCheck failed for '{Title}'", proposedTitle); throw; } } public async Task ReviewCheckAsync( int checkId, byte reviewStatus, string? reviewRemarks, LoginDTO login, CancellationToken ct) { if (checkId == 0) throw new ArgumentException("CheckId must be a positive number."); if (reviewStatus is < 1 or > 4) throw new ArgumentException("ReviewStatus must be 1-4 (ConfirmedDuplicate/NotDuplicate/WillExtend/WillCreateNew)."); try { GB5Trace.Step("content-discovery-review", new { checkId, reviewStatus }); await _dal.UpdateReviewStatusAsync(checkId, reviewStatus, login.UserId, reviewRemarks, login, ct) .ConfigureAwait(false); return $"{SuccessResponse.UpdateSuccess} {checkId}"; } catch (Exception ex) { GB5Trace.MarkFailed("content-discovery-review-failed", ex); _logger.LogError(ex, "ReviewCheck failed for CheckId {CheckId}", checkId); throw; } } public async Task> GetPendingAsync(LoginDTO login, CancellationToken ct) => await _dal.GetPendingAsync(login.ClientId, login, ct).ConfigureAwait(false); // ── Private helpers — same technique as AMP's ApiRequestDuplicateCheckBLL ────────────── private List ExtractKeywords(string? input) { if (string.IsNullOrWhiteSpace(input)) return []; return input .Split([',', ' ', '-', '_'], StringSplitOptions.RemoveEmptyEntries) .Select(w => w.ToLowerInvariant().Trim()) .Where(w => w.Length > 2 && !StopWords.Contains(w)) .Distinct() .Take(5) .ToList(); } private static (string, string, string, string, string) PadKeywords(List kws) { while (kws.Count < 5) kws.Add(string.Empty); return (kws[0], kws[1], kws[2], kws[3], kws[4]); } // Reports don't have an "operations"/"parameters" complexity axis like APIs do, so this is a // deliberately simpler 2-way suggestion (vs. AMP's 5-way) — see the QB file's header comment. private static (byte SuggestionType, string SuggestedChange) DetermineSuggestion( ReportSimilarityResultDTO match, int viewCount) { if (match.OverallScore >= 55) return (0, $"'{match.MatchedReportName}' appears to duplicate what you're proposing (score {match.OverallScore}). Review it before building anything new."); return (1, $"'{match.MatchedReportName}' (already has {viewCount} view(s)) covers related scope (score {match.OverallScore}). Consider adding a new MREPORTVIEW to it instead of a new report."); } private static ContentDiscoveryCheckSaveDTO BuildSaveDto( int id, string proposedTitle, string? proposedKeywords, int proposedModuleId, byte kind, ReportSimilarityResultDTO match, byte suggestionType, string suggestedChange, LoginDTO login) { return new ContentDiscoveryCheckSaveDTO { CheckId = id, TenantId = login.ClientId, Kind = kind, ProposedTitle = proposedTitle, ProposedKeywords = proposedKeywords, ProposedModuleId = proposedModuleId, MatchedReportId = match.MatchedReportId, OverallScore = match.OverallScore, NameScore = match.NameScore, ModuleScore = match.ModuleScore, IntentScore = match.IntentScore, SuggestionType = suggestionType, SuggestedChange = suggestedChange, MatchReason = $"Name:{match.NameScore} Module:{match.ModuleScore} Intent:{match.IntentScore}", ReviewStatus = 0 }; } private static ContentDiscoveryCheckDTO ToReadDto(ContentDiscoveryCheckSaveDTO dto) { return new ContentDiscoveryCheckDTO { CheckId = dto.CheckId, TenantId = dto.TenantId, Kind = dto.Kind, ProposedTitle = dto.ProposedTitle, ProposedKeywords = dto.ProposedKeywords, ProposedModuleId = dto.ProposedModuleId, MatchedReportId = dto.MatchedReportId, OverallScore = dto.OverallScore, NameScore = dto.NameScore, ModuleScore = dto.ModuleScore, IntentScore = dto.IntentScore, SuggestionType = dto.SuggestionType, SuggestedChange = dto.SuggestedChange, MatchReason = dto.MatchReason, ReviewStatus = 0 }; } }