From 4376a3d854e7892b6c54bd60153761f14da0f680 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Mon, 16 Mar 2026 10:24:07 -0700 Subject: [PATCH 01/13] AB#32338 support pptx attachment text extraction --- .../AI/TextExtractionService.cs | 332 ++++++++++++++++-- .../ChefsAttachments/ChefsAttachments.js | 14 +- .../ChefsAttachments/Default.cshtml | 2 +- 3 files changed, 306 insertions(+), 42 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs index 54df521637..0fb7ab5260 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs @@ -2,12 +2,14 @@ using NPOI.SS.UserModel; using NPOI.XWPF.UserModel; using System; -using System.Collections.Generic; using System.IO; +using System.IO.Compression; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Xml.Linq; using UglyToad.PdfPig; using Volo.Abp.DependencyInjection; @@ -22,6 +24,7 @@ public partial class TextExtractionService : ITextExtractionService, ITransientD private const int MaxDocxParagraphs = 2000; private const int MaxDocxTableRows = 2000; private const int MaxDocxTableCellsPerRow = 50; + private const int MaxPowerPointSlides = 200; private readonly ILogger _logger; private readonly Dictionary> _extractorsByExtension; @@ -37,7 +40,8 @@ public TextExtractionService(ILogger logger) [".pdf"] = ExtractTextFromPdfFile, [".docx"] = ExtractTextFromWordDocx, [".xls"] = ExtractTextFromExcelFile, - [".xlsx"] = ExtractTextFromExcelFile + [".xlsx"] = ExtractTextFromExcelFile, + [".pptx"] = ExtractTextFromPowerPointFile }; } @@ -92,6 +96,13 @@ public Task ExtractTextAsync(string fileName, byte[] fileContent, string return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } + if (normalizedContentType.Contains("presentation") || + normalizedContentType.Contains("powerpoint")) + { + var rawText = ExtractTextFromPowerPointFile(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + } + _logger.LogDebug("No text extraction available for content type {ContentType} with extension {Extension}", contentType, extension); return Task.FromResult(string.Empty); @@ -120,6 +131,7 @@ private string ExtractTextFromTextFile(byte[] fileContent) _logger.LogDebug("Truncated text content to {MaxLength} characters", MaxExtractedTextLength); } + _logger.LogDebug("Extracted {CharacterCount} characters from text-based content.", text.Length); return text; } catch (Exception ex) @@ -136,12 +148,28 @@ private string ExtractTextFromPdfFile(string fileName, byte[] fileContent) using var stream = new MemoryStream(fileContent, writable: false); using var document = PdfDocument.Open(stream); var builder = new StringBuilder(); - var pageTexts = document.GetPages() - .Select(page => page.Text) - .Where(pageText => !string.IsNullOrWhiteSpace(pageText)); + var processedPageCount = 0; + + foreach (var page in document.GetPages()) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } - AppendUntilLimit(builder, pageTexts); + if (string.IsNullOrWhiteSpace(page.Text)) + { + continue; + } + processedPageCount++; + if (TryAppendWithTrailingNewline(builder, page.Text)) + { + break; + } + } + + _logger.LogDebug("Extracted PDF text from {ProcessedPageCount} pages for {FileName}", processedPageCount, fileName); return builder.ToString(); } catch (Exception ex) @@ -158,15 +186,14 @@ private string ExtractTextFromWordDocx(string fileName, byte[] fileContent) using var stream = new MemoryStream(fileContent, writable: false); using var document = new XWPFDocument(stream); var builder = new StringBuilder(); - var paragraphTexts = document.Paragraphs - .Take(MaxDocxParagraphs) - .Select(paragraph => paragraph.ParagraphText) - .Where(paragraphText => !string.IsNullOrWhiteSpace(paragraphText)); - - AppendUntilLimit(builder, paragraphTexts); - - TryAppendDocxTableText(document, builder); - + var processedParagraphCount = AppendDocxParagraphText(document, builder); + var processedTableRowCount = AppendDocxTableText(document, builder); + + _logger.LogDebug( + "Extracted Word text from {ProcessedParagraphCount} paragraphs and {ProcessedTableRowCount} table rows for {FileName}", + processedParagraphCount, + processedTableRowCount, + fileName); return builder.ToString(); } catch (Exception ex) @@ -176,28 +203,72 @@ private string ExtractTextFromWordDocx(string fileName, byte[] fileContent) } } - private static void TryAppendDocxTableText(XWPFDocument document, StringBuilder builder) + private static int AppendDocxParagraphText(XWPFDocument document, StringBuilder builder) + { + var processedParagraphCount = 0; + + foreach (var paragraph in document.Paragraphs.Take(MaxDocxParagraphs)) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + if (string.IsNullOrWhiteSpace(paragraph.ParagraphText)) + { + continue; + } + + processedParagraphCount++; + if (TryAppendWithTrailingNewline(builder, paragraph.ParagraphText)) + { + break; + } + } + + return processedParagraphCount; + } + + private static int AppendDocxTableText(XWPFDocument document, StringBuilder builder) { if (builder.Length >= MaxExtractedTextLength) { - return; + return 0; } + var processedTableRowCount = 0; foreach (var table in document.Tables) { foreach (var row in table.Rows.Take(MaxDocxTableRows)) { + if (builder.Length >= MaxExtractedTextLength) + { + return processedTableRowCount; + } + var cellTexts = row.GetTableCells() .Take(MaxDocxTableCellsPerRow) .Select(cell => cell.GetText()) .Where(cellText => !string.IsNullOrWhiteSpace(cellText)); - if (AppendUntilLimit(builder, cellTexts)) + var rowHadValue = false; + foreach (var cellText in cellTexts) { - return; + rowHadValue = true; + if (TryAppendWithTrailingNewline(builder, cellText)) + { + return processedTableRowCount + 1; + } + } + + if (rowHadValue) + { + processedTableRowCount++; } } } + + return processedTableRowCount; } private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) @@ -208,6 +279,8 @@ private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) using var workbook = WorkbookFactory.Create(stream); var builder = new StringBuilder(); var sheetCount = Math.Min(workbook.NumberOfSheets, MaxExcelSheets); + var processedSheetCount = 0; + var processedRowCount = 0; for (var sheetIndex = 0; sheetIndex < sheetCount; sheetIndex++) { @@ -217,13 +290,24 @@ private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) } var sheet = workbook.GetSheetAt(sheetIndex); - var limitReached = TryAppendExcelSheet(sheet, builder); + var (rowsProcessed, limitReached) = TryAppendExcelSheet(sheet, builder); + if (rowsProcessed > 0) + { + processedSheetCount++; + processedRowCount += rowsProcessed; + } + if (limitReached) { break; } } + _logger.LogDebug( + "Extracted Excel text from {ProcessedSheetCount} sheets and {ProcessedRowCount} rows for {FileName}", + processedSheetCount, + processedRowCount, + fileName); return builder.ToString(); } catch (Exception ex) @@ -233,11 +317,94 @@ private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) } } - private static bool TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) + private string ExtractTextFromPowerPointFile(string fileName, byte[] fileContent) + { + try + { + using var stream = new MemoryStream(fileContent, writable: false); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); + var builder = new StringBuilder(); + var slideEntries = GetOrderedPowerPointSlideEntries(archive) + .Take(MaxPowerPointSlides); + var processedSlideCount = 0; + + foreach (var slideEntry in slideEntries) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + using var slideStream = slideEntry.Open(); + var slideText = ExtractPowerPointSlideText(slideStream); + if (string.IsNullOrWhiteSpace(slideText)) + { + continue; + } + + processedSlideCount++; + if (TryAppendWithTrailingNewline(builder, slideText)) + { + break; + } + } + + _logger.LogDebug("Extracted PowerPoint text from {ProcessedSlideCount} slides for {FileName}", processedSlideCount, fileName); + return builder.ToString(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "PowerPoint (.pptx) text extraction failed for {FileName}", fileName); + return string.Empty; + } + } + + private IEnumerable GetOrderedPowerPointSlideEntries(ZipArchive archive) + { + var slideEntriesByName = archive.Entries + .Where(entry => entry.FullName.StartsWith("ppt/slides/slide", StringComparison.OrdinalIgnoreCase) && + entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(entry => entry.FullName, StringComparer.OrdinalIgnoreCase); + + if (slideEntriesByName.Count == 0) + { + _logger.LogDebug("No slide entries found in PowerPoint archive."); + return Enumerable.Empty(); + } + + var orderedSlideNames = TryGetPowerPointSlideOrder(archive); + if (orderedSlideNames.Count == 0) + { + _logger.LogDebug("Using PowerPoint part-name order fallback for {SlideCount} slides.", slideEntriesByName.Count); + return slideEntriesByName.Values + .OrderBy(entry => GetPowerPointSlideNumber(entry.FullName)) + .ToList(); + } + + var orderedEntries = new List(slideEntriesByName.Count); + foreach (var slideName in orderedSlideNames) + { + if (slideEntriesByName.TryGetValue(slideName, out var slideEntry)) + { + orderedEntries.Add(slideEntry); + slideEntriesByName.Remove(slideName); + } + } + + if (slideEntriesByName.Count > 0) + { + orderedEntries.AddRange(slideEntriesByName.Values.OrderBy(entry => GetPowerPointSlideNumber(entry.FullName))); + } + + _logger.LogDebug("Resolved PowerPoint presentation order for {SlideCount} slides.", orderedEntries.Count); + return orderedEntries; + } + + private static (int RowsProcessed, bool LimitReached) TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) { if (sheet == null) { - return false; + return (0, false); } var processedRows = 0; @@ -248,18 +415,22 @@ private static bool TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) break; } - var limitReached = TryAppendExcelRow(row, builder); - processedRows++; + var (rowHadValue, limitReached) = TryAppendExcelRow(row, builder); + if (rowHadValue) + { + processedRows++; + } + if (limitReached) { - return true; + return (processedRows, true); } } - return builder.Length >= MaxExtractedTextLength; + return (processedRows, builder.Length >= MaxExtractedTextLength); } - private static bool TryAppendExcelRow(IRow row, StringBuilder builder) + private static (bool RowHadValue, bool LimitReached) TryAppendExcelRow(IRow row, StringBuilder builder) { var rowHasValue = false; foreach (var cell in row.Cells.Take(MaxExcelCellsPerRow)) @@ -280,7 +451,7 @@ private static bool TryAppendExcelRow(IRow row, StringBuilder builder) rowHasValue = true; if (limitReached) { - return true; + return (true, true); } } @@ -290,7 +461,7 @@ private static bool TryAppendExcelRow(IRow row, StringBuilder builder) builder.Append(Environment.NewLine); } - return builder.Length >= MaxExtractedTextLength; + return (rowHasValue, builder.Length >= MaxExtractedTextLength); } private static bool TryAppendWithTrailingNewline(StringBuilder builder, string? value) @@ -309,10 +480,107 @@ private static bool TryAppendWithTrailingNewline(StringBuilder builder, string? return builder.Length >= MaxExtractedTextLength; } - private static bool AppendUntilLimit(StringBuilder builder, IEnumerable texts) + private static string ExtractPowerPointSlideText(Stream slideStream) { - var limitReached = texts.Any(text => TryAppendWithTrailingNewline(builder, text)); - return limitReached || builder.Length >= MaxExtractedTextLength; + var document = XDocument.Load(slideStream); + XNamespace drawingNamespace = "http://schemas.openxmlformats.org/drawingml/2006/main"; + var textRuns = document + .Descendants(drawingNamespace + "t") + .Select(node => node.Value?.Trim()) + .Where(value => !string.IsNullOrWhiteSpace(value)); + + return string.Join(Environment.NewLine, textRuns); + } + + private static int GetPowerPointSlideNumber(string entryName) + { + var fileName = Path.GetFileNameWithoutExtension(entryName); + if (string.IsNullOrWhiteSpace(fileName)) + { + return int.MaxValue; + } + + var slideNumberText = fileName.Substring("slide".Length); + return int.TryParse(slideNumberText, out var slideNumber) + ? slideNumber + : int.MaxValue; + } + + private List TryGetPowerPointSlideOrder(ZipArchive archive) + { + try + { + var presentationEntry = archive.GetEntry("ppt/presentation.xml"); + var relationshipsEntry = archive.GetEntry("ppt/_rels/presentation.xml.rels"); + if (presentationEntry == null || relationshipsEntry == null) + { + return new List(); + } + + using var presentationStream = presentationEntry.Open(); + using var relationshipsStream = relationshipsEntry.Open(); + var presentationDocument = XDocument.Load(presentationStream); + var relationshipsDocument = XDocument.Load(relationshipsStream); + + XNamespace presentationNamespace = "http://schemas.openxmlformats.org/presentationml/2006/main"; + XNamespace officeDocumentRelationshipsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + XNamespace packageRelationshipsNamespace = "http://schemas.openxmlformats.org/package/2006/relationships"; + + var slideTargetsByRelationshipId = relationshipsDocument + .Root? + .Elements(packageRelationshipsNamespace + "Relationship") + .Where(element => string.Equals( + element.Attribute("Type")?.Value, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide", + StringComparison.OrdinalIgnoreCase)) + .Select(element => new + { + Id = element.Attribute("Id")?.Value, + Target = NormalizePowerPointSlideTarget(element.Attribute("Target")?.Value) + }) + .Where(item => !string.IsNullOrWhiteSpace(item.Id) && !string.IsNullOrWhiteSpace(item.Target)) + .ToDictionary(item => item.Id!, item => item.Target!, StringComparer.OrdinalIgnoreCase); + + return presentationDocument + .Descendants(presentationNamespace + "sldId") + .Select(element => element.Attribute(officeDocumentRelationshipsNamespace + "id")?.Value) + .Where(relationshipId => !string.IsNullOrWhiteSpace(relationshipId)) + .Select(relationshipId => slideTargetsByRelationshipId.GetValueOrDefault(relationshipId!)) + .Where(target => !string.IsNullOrWhiteSpace(target)) + .Cast() + .ToList(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Falling back to part-name slide order for PowerPoint extraction."); + return new List(); + } + } + + private static string? NormalizePowerPointSlideTarget(string? target) + { + if (string.IsNullOrWhiteSpace(target)) + { + return null; + } + + var normalizedTarget = target.Replace('\\', '/').TrimStart('/'); + if (normalizedTarget.StartsWith("ppt/", StringComparison.OrdinalIgnoreCase)) + { + return normalizedTarget; + } + + if (normalizedTarget.StartsWith("slides/", StringComparison.OrdinalIgnoreCase)) + { + return $"ppt/{normalizedTarget}"; + } + + if (normalizedTarget.StartsWith("../", StringComparison.OrdinalIgnoreCase)) + { + normalizedTarget = normalizedTarget.Substring(3); + } + + return $"ppt/{normalizedTarget}"; } private static void AppendTrailingNewlineIfRoom(StringBuilder builder) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index e9d1d60177..3e98c528a7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -310,9 +310,7 @@ $(function () { $toggleAllAISummariesButton.on('click', function () { const $button = $(this); const $icon = $button.find('i'); - const $text = $button.contents().filter(function () { - return this.nodeType === 3; - }); + const $text = $button.find('.toggle-ai-summaries-label'); // Don't do anything if button is disabled if ($button.prop('disabled')) { @@ -339,7 +337,7 @@ $(function () { } }); $icon.removeClass('fa-chevron-up').addClass('fa-chevron-down'); - $text.replaceWith('Show Summaries'); + $text.text('Show Summaries'); $button.attr('title', 'Show AI Summaries'); allAISummariesExpanded = false; } else { @@ -367,7 +365,7 @@ $(function () { } }); $icon.removeClass('fa-chevron-down').addClass('fa-chevron-up'); - $text.replaceWith('Hide Summaries'); + $text.text('Hide Summaries'); $button.attr('title', 'Hide AI Summaries'); allAISummariesExpanded = true; } @@ -379,11 +377,9 @@ $(function () { if (allAISummariesExpanded) { const $button = $('#toggleAllAISummaries'); const $icon = $button.find('i'); - const $text = $button.contents().filter(function () { - return this.nodeType === 3; - }); + const $text = $button.find('.toggle-ai-summaries-label'); $icon.removeClass('fa-chevron-up').addClass('fa-chevron-down'); - $text.replaceWith('Show Summaries'); + $text.text('Show Summaries'); $button.attr('title', 'Show AI Summaries'); allAISummariesExpanded = false; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml index 6e56c53e13..2d599704dd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml @@ -18,7 +18,7 @@ } From 80137cbe17bac51c536e8b6e6e6302e315a4fd42 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Mon, 16 Mar 2026 10:30:21 -0700 Subject: [PATCH 02/13] AB#32338 fix pptx extraction nullable warning --- .../AI/TextExtractionService.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs index 0fb7ab5260..04b0da788b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs @@ -526,7 +526,7 @@ private List TryGetPowerPointSlideOrder(ZipArchive archive) XNamespace officeDocumentRelationshipsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; XNamespace packageRelationshipsNamespace = "http://schemas.openxmlformats.org/package/2006/relationships"; - var slideTargetsByRelationshipId = relationshipsDocument + var slideTargetsByRelationshipId = (relationshipsDocument .Root? .Elements(packageRelationshipsNamespace + "Relationship") .Where(element => string.Equals( @@ -539,7 +539,8 @@ private List TryGetPowerPointSlideOrder(ZipArchive archive) Target = NormalizePowerPointSlideTarget(element.Attribute("Target")?.Value) }) .Where(item => !string.IsNullOrWhiteSpace(item.Id) && !string.IsNullOrWhiteSpace(item.Target)) - .ToDictionary(item => item.Id!, item => item.Target!, StringComparer.OrdinalIgnoreCase); + .ToDictionary(item => item.Id!, item => item.Target!, StringComparer.OrdinalIgnoreCase)) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); return presentationDocument .Descendants(presentationNamespace + "sldId") From c21ab3ee459db0369352905d8de468fee7d3ce67 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Mon, 16 Mar 2026 10:38:16 -0700 Subject: [PATCH 03/13] AB#32339 align AI scoring flow and review actions --- .../Handlers/GenerateAIContentHandler.cs | 12 +- .../Pages/GrantApplications/Details.cshtml | 53 ++- .../AssessmentScoresWidgetViewComponent.cs | 10 +- .../AssessmentScoresWidgetViewModel.cs | 2 + .../AssessmentScoresWidget/Default.cshtml | 15 +- .../Components/ReviewList/Default.cshtml | 10 +- .../Components/ReviewList/ReviewList.cs | 19 +- .../Components/ReviewList/ReviewList.css | 6 + .../Components/ReviewList/ReviewList.js | 401 +++++++++++------- 9 files changed, 335 insertions(+), 193 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs index b06e30aae2..21cc065690 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs @@ -23,6 +23,7 @@ public class GenerateAIContentHandler : ILocalEventHandler @@ -67,6 +72,8 @@ + + @functions { @@ -493,12 +500,15 @@
Attachment
- + @if (aiAttachmentSummariesEnabled) + { + + }
@@ -533,12 +543,15 @@
Scoring
- + @if (aiScoringEnabled) + { + + }
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs index bbcab59471..53b88bfd8d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs @@ -17,6 +17,9 @@ using Unity.GrantManager.AI; using Unity.GrantManager.Applications; using System.Text.Json; +using Unity.AI.Permissions; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Features; namespace Unity.GrantManager.Web.Views.Shared.Components.AssessmentScoresWidget { @@ -28,7 +31,9 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.AssessmentScoresWidget public class AssessmentScoresWidgetViewComponent(IAssessmentRepository assessmentRepository, IScoresheetRepository scoresheetRepository, IScoresheetInstanceRepository scoresheetInstanceRepository, - IApplicationRepository applicationRepository) : AbpViewComponent + IApplicationRepository applicationRepository, + IFeatureChecker featureChecker, + IPermissionChecker permissionChecker) : AbpViewComponent { public async Task InvokeAsync(Guid assessmentId, Guid currentUserId) { @@ -94,6 +99,9 @@ public async Task InvokeAsync(Guid assessmentId, Guid curr Status = assessment.Status, CurrentUserId = currentUserId, AssessorId = assessment.AssessorId, + IsAIScoringEnabled = await featureChecker.IsEnabledAsync("Unity.AI.Scoring") && + await permissionChecker.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault), + IsAiAssessment = assessment.IsAiAssessment, }; return View(model); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs index 4a74d0c5b3..a2f595173b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs @@ -25,6 +25,8 @@ public class AssessmentScoresWidgetViewModel public Guid CurrentUserId { get; set; } public Guid AssessorId { get; set; } public ScoresheetDto? Scoresheet { get; set; } + public bool IsAIScoringEnabled { get; set; } + public bool IsAiAssessment { get; set; } public bool IsDisabled() { if(CurrentUserId != AssessorId) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index ab766c0069..6fcb6be0be 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -22,12 +22,15 @@
Assessment Scores
- + @if (Model.IsAIScoringEnabled && Model.IsAiAssessment) + { + + } +} +``` + +### 4. Distributed Events for Workflow Management +**Use Cases**: Application state changes, notifications, audit trail, integration with external systems + +ABP 9.1.3 improves distributed event handling with better inbox/outbox pattern support. + +```csharp +// Define event (in Domain.Shared) +[Serializable] +public class ApplicationApprovedEto : EtoBase +{ + public Guid ApplicationId { get; set; } + public decimal ApprovedAmount { get; set; } +} + +// Publish event (in Application Service or Domain Entity) +await _distributedEventBus.PublishAsync(new ApplicationApprovedEto +{ + ApplicationId = id, + ApprovedAmount = amount +}); + +// Handle event (in Application layer) +public class ApplicationApprovedEventHandler : + IDistributedEventHandler, + ITransientDependency +{ + private readonly IEmailSender _emailSender; + + public ApplicationApprovedEventHandler(IEmailSender emailSender) + { + _emailSender = emailSender; + } + + public async Task HandleEventAsync(ApplicationApprovedEto eventData) + { + // Send approval email + // Create payment record + // Update external systems + } +} +``` + +**Configure Outbox for Reliability**: +```csharp +Configure(options => +{ + options.Outboxes.Configure(config => + { + config.UseDbContext(); + }); +}); +``` + +### 5. Enhanced Audit Logging +**Use Cases**: Track all changes to grant applications, compliance reporting, user activity monitoring + +ABP 9.1.3 provides better audit log filtering and querying. + +```csharp +// Disable auditing for specific method +[DisableAuditing] +public async Task GetLargeReportAsync() +{ + // Method not audited +} + +// Custom audit log properties +public class GrantApplicationAppService : ApplicationService +{ + public async Task ApproveAsync(Guid id, decimal amount) + { + // Add custom audit data + AuditingManager.Current.Log.EntityChanges.Add(new EntityChangeInfo + { + ChangeType = EntityChangeType.Updated, + EntityId = id.ToString(), + PropertyChanges = new List + { + new EntityPropertyChangeInfo + { + PropertyName = "ApprovalAmount", + NewValue = amount.ToString(), + OriginalValue = "0" + } + } + }); + } +} + +// Query audit logs (in a service) +var auditLogs = await _auditLogRepository.GetListAsync( + includeDetails: true, + httpMethod: "POST", + url: "/api/app/grant-application", + userName: "admin", + startTime: DateTime.UtcNow.AddDays(-7), + endTime: DateTime.UtcNow +); +``` + +### 6. Setting Management for Configurable Parameters +**Use Cases**: Approval thresholds, deadline configurations, scoring weights, notification preferences + +```csharp +// Define settings (in Domain.Shared) +public static class GrantManagerSettings +{ + public const string ApprovalThreshold = "GrantManager.ApprovalThreshold"; + public const string MaxApplicationsPerUser = "GrantManager.MaxApplicationsPerUser"; + public const string AutoCloseDeadlineDays = "GrantManager.AutoCloseDeadlineDays"; +} + +// Define setting definition provider +public class GrantManagerSettingDefinitionProvider : SettingDefinitionProvider +{ + public override void Define(ISettingDefinitionContext context) + { + context.Add( + new SettingDefinition( + GrantManagerSettings.ApprovalThreshold, + "100000", + isVisibleToClients: true, + isEncrypted: false + ), + new SettingDefinition( + GrantManagerSettings.MaxApplicationsPerUser, + "5", + isVisibleToClients: true + ) + ); + } +} + +// Use settings in code +var threshold = await SettingProvider.GetAsync(GrantManagerSettings.ApprovalThreshold); + +if (amount > threshold) +{ + // Require additional approval +} + +// Get setting in JavaScript +var maxApps = await abp.setting.get('GrantManager.MaxApplicationsPerUser'); +``` + +### 7. Dynamic Claims for Custom Authorization +**Use Cases**: Department-based access, region-based filtering, role-based data visibility + +```csharp +// Define custom claim type +public static class GrantManagerClaims +{ + public const string Department = "GrantManager_Department"; + public const string Region = "GrantManager_Region"; + public const string MaxApprovalAmount = "GrantManager_MaxApprovalAmount"; +} + +// Add dynamic claims (in Identity module) +public class GrantManagerClaimsPrincipalContributor : IAbpClaimsPrincipalContributor, ITransientDependency +{ + public async Task ContributeAsync(AbpClaimsPrincipalContributorContext context) + { + var identity = context.ClaimsPrincipal.Identities.FirstOrDefault(); + var userId = identity?.FindUserId(); + + if (userId.HasValue) + { + // Add custom claims from user profile or database + var userDepartment = await GetUserDepartmentAsync(userId.Value); + identity?.AddClaim(new Claim(GrantManagerClaims.Department, userDepartment)); + } + } +} + +// Use in authorization +[Authorize] +public async Task> GetMyDepartmentApplicationsAsync() +{ + var department = CurrentUser.FindClaimValue(GrantManagerClaims.Department); + return await _repository.GetListAsync(x => x.Department == department); +} +``` + +### 8. EF Core 8 Features (if using .NET 8+) +**New Capabilities**: JSON columns, raw SQL queries, complex type mapping + +```csharp +// JSON column mapping (for flexible metadata) +public class GrantApplication : FullAuditedAggregateRoot +{ + public string ReferenceNo { get; set; } + public ApplicationMetadata Metadata { get; set; } // Stored as JSON +} + +// In DbContext configuration +protected override void OnModelCreating(ModelBuilder builder) +{ + builder.Entity(b => + { + b.OwnsOne(e => e.Metadata, b => b.ToJson()); + }); +} + +// Raw SQL queries with better performance +var applications = await _dbContext.Database + .SqlQuery($"EXEC GetTopApplications @Year = {year}") + .ToListAsync(); +``` + +### 9. Object Extension System for Extensibility +**Use Cases**: Add custom fields without modifying core entities + +```csharp +// Configure in EntityFrameworkCore module +ObjectExtensionManager.Instance + .AddOrUpdateProperty( + "CustomField1", + options => { options.MapEfCore(b => b.HasMaxLength(128)); } + ); + +// Use in application service +application.SetProperty("CustomField1", "CustomValue"); +var value = application.GetProperty("CustomField1"); +``` + +### 10. Text Template Management +**Use Cases**: Email templates, document generation, notification templates + +```csharp +// Define template +public class ApprovalEmailTemplate : TemplateDefinitionProvider +{ + public override void Define(ITemplateDefinitionContext context) + { + context.Add( + new TemplateDefinition("ApprovalEmail") + .WithVirtualFilePath("/Templates/ApprovalEmail.tpl", isInlineLocalized: true) + ); + } +} + +// Use template +var emailBody = await _templateRenderer.RenderAsync( + "ApprovalEmail", + new { ApplicantName = "John Doe", Amount = 50000 } +); +``` + +## Module Structure +Unity Grant Manager includes: +- **Unity.Shared**: Shared components across Unity applications +- **MessageBrokers**: RabbitMQ integration (consider using ABP distributed events) +- **modules/**: Various ABP modules + +## Additional Resources +- ABP Framework Documentation: https://docs.abp.io +- ABP 9.1 Release Notes: https://docs.abp.io/en/abp/9.1/Release-Info +- Project README: `/Unity/applications/Unity.GrantManager/README.md` +- Architecture documentation: `/Unity/documentation/` + +## Recommended Next Steps for ABP 9.1.3 Integration + +1. **Implement Blob Storage** for document management (replace file system storage) +2. **Add Distributed Events** for application workflow state changes +3. **Configure Background Jobs** for report generation and notifications +4. **Use Setting Management** for configurable business rules (thresholds, deadlines) +5. **Leverage Global Features** for feature flags in production +6. **Enhance Audit Logging** for compliance requirements +7. **Implement Dynamic Claims** for department/region-based access control +8. **Use Text Templates** for standardized email and document generation + +--- + +**Remember**: This is an ABP Framework MVC application, NOT Angular. Use Razor views, jQuery, and traditional server-side rendering patterns.