~#;lt=s^6_OccmRd>o{*=>)KS=lM
zZ!)iG|8G0-9s3VLm`bsa6e
ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_&
z9OZE;->dO@`Q)nr<%dHAsEZRKl
zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U
zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ
z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9
zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV
zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z
zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4
zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh
zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{
z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j(
zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/EfpAnalyzer/tests/EfpAnalyzer.Tests/UnitTest1.cs b/EfpAnalyzer/tests/EfpAnalyzer.Tests/UnitTest1.cs
new file mode 100644
index 0000000..c0b6c53
--- /dev/null
+++ b/EfpAnalyzer/tests/EfpAnalyzer.Tests/UnitTest1.cs
@@ -0,0 +1,10 @@
+namespace EfpAnalyzer.Tests;
+
+public class UnitTest1
+{
+ [Fact]
+ public void Test1()
+ {
+
+ }
+}
From 1b54f723eba3c3bf5517ab89269b579b008c1e28 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 19:15:58 +0000
Subject: [PATCH 03/17] Add C# service files (DocumentProcessorService,
ScoringService, ComparisonService) and Azure NuGet packages
Co-authored-by: amgdy <1763289+amgdy@users.noreply.github.com>
---
EfpAnalyzer/EfpAnalyzer/EfpAnalyzer.csproj | 2 +
.../EfpAnalyzer/Services/ComparisonService.cs | 447 +++++++++++++++
.../Services/DocumentProcessorService.cs | 185 +++++++
.../EfpAnalyzer/Services/ScoringService.cs | 513 ++++++++++++++++++
4 files changed, 1147 insertions(+)
create mode 100644 EfpAnalyzer/EfpAnalyzer/Services/ComparisonService.cs
create mode 100644 EfpAnalyzer/EfpAnalyzer/Services/DocumentProcessorService.cs
create mode 100644 EfpAnalyzer/EfpAnalyzer/Services/ScoringService.cs
diff --git a/EfpAnalyzer/EfpAnalyzer/EfpAnalyzer.csproj b/EfpAnalyzer/EfpAnalyzer/EfpAnalyzer.csproj
index 0545270..fcb5249 100644
--- a/EfpAnalyzer/EfpAnalyzer/EfpAnalyzer.csproj
+++ b/EfpAnalyzer/EfpAnalyzer/EfpAnalyzer.csproj
@@ -8,6 +8,8 @@
+
+
diff --git a/EfpAnalyzer/EfpAnalyzer/Services/ComparisonService.cs b/EfpAnalyzer/EfpAnalyzer/Services/ComparisonService.cs
new file mode 100644
index 0000000..710c1c2
--- /dev/null
+++ b/EfpAnalyzer/EfpAnalyzer/Services/ComparisonService.cs
@@ -0,0 +1,447 @@
+using System.Globalization;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using Azure.Core;
+using Azure.Identity;
+using ClosedXML.Excel;
+using EfpAnalyzer.Models;
+
+namespace EfpAnalyzer.Services;
+
+public class ComparisonService
+{
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger _logger;
+ private readonly TokenCredential _credential;
+
+ private const string ComparisonInstructions = """
+ You are an expert procurement analyst specializing in vendor comparison and selection.
+
+ Your task is to analyze evaluation results from multiple vendors responding to the same RFP
+ and provide a comprehensive comparative analysis.
+
+ ## YOUR RESPONSIBILITIES:
+
+ 1. **Rank Vendors**: Order vendors by total score, identifying the best performer
+ 2. **Compare by Criterion**: Analyze how vendors performed on each evaluation criterion
+ 3. **Identify Patterns**: Find strengths and weaknesses across the vendor pool
+ 4. **Provide Insights**: Offer actionable insights for the selection committee
+ 5. **Make Recommendations**: Provide clear selection recommendations
+
+ ## ANALYSIS APPROACH:
+
+ For each vendor:
+ - Review their total score and individual criterion scores
+ - Identify their top 3 strengths and top 3 concerns
+ - Assess their suitability for the project
+
+ For the comparison:
+ - Identify which vendors excel in which areas
+ - Note any significant score gaps between vendors
+ - Highlight criteria where all vendors performed well or poorly
+ - Consider risk factors and value for money
+
+ ## OUTPUT FORMAT:
+
+ Respond with a valid JSON object:
+
+ ```json
+ {
+ "rfp_title": "RFP title",
+ "comparison_date": "YYYY-MM-DD",
+ "total_vendors": ,
+ "vendor_rankings": [
+ {
+ "rank": 1,
+ "vendor_name": "Vendor Name",
+ "total_score": 85.5,
+ "grade": "B",
+ "key_strengths": ["strength 1", "strength 2", "strength 3"],
+ "key_concerns": ["concern 1", "concern 2", "concern 3"],
+ "recommendation": "Brief recommendation for this vendor"
+ }
+ ],
+ "criterion_comparisons": [
+ {
+ "criterion_id": "C-1",
+ "criterion_name": "Criterion Name",
+ "weight": 20.0,
+ "best_vendor": "Best Vendor",
+ "worst_vendor": "Worst Vendor",
+ "score_range": "65-92",
+ "insights": "Key insight for this criterion"
+ }
+ ],
+ "winner_summary": "Summary of why the top vendor is recommended",
+ "comparison_insights": [
+ "Key insight 1",
+ "Key insight 2",
+ "Key insight 3"
+ ],
+ "selection_recommendation": "Clear final recommendation with justification",
+ "risk_comparison": "Comparative risk assessment across vendors"
+ }
+ ```
+
+ ## IMPORTANT:
+ - Rank ALL vendors by score
+ - Compare ALL criteria
+ - Be objective and fair
+ - Support recommendations with evidence
+ - Respond with ONLY valid JSON
+ """;
+
+ public ComparisonService(
+ IHttpClientFactory httpClientFactory,
+ IConfiguration configuration,
+ ILogger logger)
+ {
+ _httpClientFactory = httpClientFactory;
+ _configuration = configuration;
+ _logger = logger;
+ _credential = new DefaultAzureCredential();
+ }
+
+ public async Task CompareEvaluationsAsync(
+ List evaluations,
+ string rfpTitle,
+ string reasoningEffort = "high",
+ Action? progressCallback = null,
+ CancellationToken ct = default)
+ {
+ _logger.LogInformation("Starting comparison of {Count} vendor evaluations", evaluations.Count);
+ progressCallback?.Invoke("Preparing vendor comparison...");
+
+ var evaluationsSummary = FormatEvaluationsForPrompt(evaluations);
+
+ var userPrompt = $"""
+ Please compare the following vendor evaluations and provide a comprehensive analysis.
+
+ ## RFP TITLE: {rfpTitle}
+
+ ## VENDOR EVALUATIONS:
+
+ {evaluationsSummary}
+
+ ---
+
+ REQUIREMENTS:
+ 1. Rank all vendors by total score
+ 2. Compare performance on each criterion
+ 3. Identify the best and worst performers per criterion
+ 4. Provide clear selection recommendations
+ 5. Assess comparative risks
+
+ Respond with ONLY valid JSON matching the schema in your instructions.
+ """;
+
+ progressCallback?.Invoke("Analyzing vendor comparisons...");
+ var responseText = await CallAzureOpenAIAsync(ComparisonInstructions, userPrompt, reasoningEffort, ct);
+
+ return ParseComparisonResponse(responseText);
+ }
+
+ private string FormatEvaluationsForPrompt(List evaluations)
+ {
+ var parts = new List();
+
+ for (int i = 0; i < evaluations.Count; i++)
+ {
+ var eval = evaluations[i];
+ var sb = new StringBuilder();
+ sb.AppendLine($"### Vendor {i + 1}: {eval.SupplierName}");
+ sb.AppendLine($"- **Total Score:** {eval.TotalScore:F2}");
+ sb.AppendLine($"- **Grade:** {eval.Grade}");
+ sb.AppendLine();
+ sb.AppendLine("**Criterion Scores:**");
+
+ foreach (var cs in eval.CriterionScores)
+ {
+ sb.AppendLine($"- {cs.CriterionName}: {cs.RawScore:F1} (weighted: {cs.WeightedScore:F2})");
+ }
+
+ if (eval.OverallStrengths.Count > 0)
+ sb.AppendLine($"\n**Strengths:** {string.Join(", ", eval.OverallStrengths.Take(5))}");
+ if (eval.OverallWeaknesses.Count > 0)
+ sb.AppendLine($"\n**Weaknesses:** {string.Join(", ", eval.OverallWeaknesses.Take(5))}");
+
+ parts.Add(sb.ToString());
+ }
+
+ return string.Join("\n\n---\n\n", parts);
+ }
+
+ private ComparisonResult ParseComparisonResponse(string responseText)
+ {
+ var text = CleanJsonResponse(responseText);
+
+ try
+ {
+ using var doc = JsonDocument.Parse(text);
+ var root = doc.RootElement;
+
+ var result = new ComparisonResult
+ {
+ RfpTitle = root.TryGetProperty("rfp_title", out var rt) ? rt.GetString() ?? "" : "",
+ ComparisonDate = root.TryGetProperty("comparison_date", out var cd) ? cd.GetString() ?? DateTime.UtcNow.ToString("yyyy-MM-dd") : DateTime.UtcNow.ToString("yyyy-MM-dd"),
+ TotalVendors = root.TryGetProperty("total_vendors", out var tv) ? tv.GetInt32() : 0,
+ WinnerSummary = root.TryGetProperty("winner_summary", out var ws) ? ws.GetString() ?? "" : "",
+ SelectionRecommendation = root.TryGetProperty("selection_recommendation", out var sr) ? sr.GetString() ?? "" : "",
+ RiskComparison = root.TryGetProperty("risk_comparison", out var rc) ? rc.GetString() ?? "" : ""
+ };
+
+ if (root.TryGetProperty("vendor_rankings", out var rankings))
+ {
+ foreach (var r in rankings.EnumerateArray())
+ {
+ result.VendorRankings.Add(new VendorRanking
+ {
+ Rank = r.TryGetProperty("rank", out var rank) ? rank.GetInt32() : 0,
+ VendorName = r.TryGetProperty("vendor_name", out var vn) ? vn.GetString() ?? "" : "",
+ TotalScore = r.TryGetProperty("total_score", out var ts) ? ts.GetDouble() : 0,
+ Grade = r.TryGetProperty("grade", out var g) ? g.GetString() ?? "" : "",
+ KeyStrengths = r.TryGetProperty("key_strengths", out var ks) ? ks.EnumerateArray().Select(s => s.GetString() ?? "").ToList() : new(),
+ KeyConcerns = r.TryGetProperty("key_concerns", out var kc) ? kc.EnumerateArray().Select(s => s.GetString() ?? "").ToList() : new(),
+ Recommendation = r.TryGetProperty("recommendation", out var rec) ? rec.GetString() ?? "" : ""
+ });
+ }
+ }
+
+ if (root.TryGetProperty("criterion_comparisons", out var comparisons))
+ {
+ foreach (var c in comparisons.EnumerateArray())
+ {
+ result.CriterionComparisons.Add(new CriterionComparison
+ {
+ CriterionId = c.TryGetProperty("criterion_id", out var cid) ? cid.GetString() ?? "" : "",
+ CriterionName = c.TryGetProperty("criterion_name", out var cn) ? cn.GetString() ?? "" : "",
+ Weight = c.TryGetProperty("weight", out var w) ? w.GetDouble() : 0,
+ BestVendor = c.TryGetProperty("best_vendor", out var bv) ? bv.GetString() ?? "" : "",
+ WorstVendor = c.TryGetProperty("worst_vendor", out var wv) ? wv.GetString() ?? "" : "",
+ ScoreRange = c.TryGetProperty("score_range", out var sr2) ? sr2.GetString() ?? "" : "",
+ Insights = c.TryGetProperty("insights", out var ins) ? ins.GetString() ?? "" : ""
+ });
+ }
+ }
+
+ if (root.TryGetProperty("comparison_insights", out var insights))
+ {
+ result.ComparisonInsights = insights.EnumerateArray().Select(s => s.GetString() ?? "").ToList();
+ }
+
+ return result;
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex, "Failed to parse comparison JSON");
+ return new ComparisonResult
+ {
+ RfpTitle = "Unknown RFP",
+ ComparisonDate = DateTime.UtcNow.ToString("yyyy-MM-dd"),
+ WinnerSummary = "Error parsing comparison results",
+ SelectionRecommendation = "Unable to provide recommendation due to parsing error"
+ };
+ }
+ }
+
+ public byte[] GenerateCsvReport(ComparisonResult comparison, List evaluations)
+ {
+ using var ms = new MemoryStream();
+ using var writer = new StreamWriter(ms, Encoding.UTF8);
+
+ writer.WriteLine("RFP Comparison Report");
+ writer.WriteLine($"RFP Title,{comparison.RfpTitle}");
+ writer.WriteLine($"Comparison Date,{comparison.ComparisonDate}");
+ writer.WriteLine($"Total Vendors,{comparison.TotalVendors}");
+ writer.WriteLine();
+
+ writer.WriteLine("=== VENDOR RANKINGS ===");
+ writer.WriteLine("Rank,Vendor Name,Total Score,Grade,Recommendation");
+ foreach (var ranking in comparison.VendorRankings)
+ {
+ writer.WriteLine($"{ranking.Rank},{ranking.VendorName},{ranking.TotalScore:F2},{ranking.Grade},{EscapeCsv(ranking.Recommendation)}");
+ }
+ writer.WriteLine();
+
+ writer.WriteLine("=== CRITERION COMPARISON ===");
+ var header = "Criterion,Weight";
+ foreach (var eval in evaluations)
+ {
+ header += $",{eval.SupplierName}";
+ }
+ writer.WriteLine(header);
+
+ if (evaluations.Count > 0)
+ {
+ var allCriteria = evaluations[0].CriterionScores;
+ for (int idx = 0; idx < allCriteria.Count; idx++)
+ {
+ var row = $"{allCriteria[idx].CriterionName},{allCriteria[idx].Weight:F1}%";
+ foreach (var eval in evaluations)
+ {
+ if (idx < eval.CriterionScores.Count)
+ row += $",{eval.CriterionScores[idx].RawScore:F1}";
+ else
+ row += ",";
+ }
+ writer.WriteLine(row);
+ }
+
+ var totalRow = "TOTAL SCORE,100%";
+ foreach (var eval in evaluations)
+ {
+ totalRow += $",{eval.TotalScore:F2}";
+ }
+ writer.WriteLine(totalRow);
+ }
+
+ writer.WriteLine();
+ writer.WriteLine("=== KEY INSIGHTS ===");
+ foreach (var insight in comparison.ComparisonInsights)
+ {
+ writer.WriteLine(EscapeCsv(insight));
+ }
+
+ writer.WriteLine();
+ writer.WriteLine("=== SELECTION RECOMMENDATION ===");
+ writer.WriteLine(EscapeCsv(comparison.SelectionRecommendation));
+
+ writer.Flush();
+ return ms.ToArray();
+ }
+
+ public byte[] GenerateExcelReport(ComparisonResult comparison, List evaluations)
+ {
+ using var workbook = new XLWorkbook();
+
+ // Rankings sheet
+ var rankingsSheet = workbook.Worksheets.Add("Rankings");
+ rankingsSheet.Cell(1, 1).Value = "Rank";
+ rankingsSheet.Cell(1, 2).Value = "Vendor";
+ rankingsSheet.Cell(1, 3).Value = "Score";
+ rankingsSheet.Cell(1, 4).Value = "Grade";
+ rankingsSheet.Cell(1, 5).Value = "Recommendation";
+ var headerRange = rankingsSheet.Range(1, 1, 1, 5);
+ headerRange.Style.Font.Bold = true;
+
+ for (int i = 0; i < comparison.VendorRankings.Count; i++)
+ {
+ var r = comparison.VendorRankings[i];
+ rankingsSheet.Cell(i + 2, 1).Value = r.Rank;
+ rankingsSheet.Cell(i + 2, 2).Value = r.VendorName;
+ rankingsSheet.Cell(i + 2, 3).Value = r.TotalScore;
+ rankingsSheet.Cell(i + 2, 4).Value = r.Grade;
+ rankingsSheet.Cell(i + 2, 5).Value = r.Recommendation;
+ }
+ rankingsSheet.Columns().AdjustToContents();
+
+ // Score comparison sheet
+ if (evaluations.Count > 0)
+ {
+ var scoresSheet = workbook.Worksheets.Add("Score Comparison");
+ scoresSheet.Cell(1, 1).Value = "Criterion";
+ scoresSheet.Cell(1, 2).Value = "Weight";
+ for (int i = 0; i < evaluations.Count; i++)
+ {
+ scoresSheet.Cell(1, i + 3).Value = evaluations[i].SupplierName;
+ }
+ var scoreHeader = scoresSheet.Range(1, 1, 1, evaluations.Count + 2);
+ scoreHeader.Style.Font.Bold = true;
+
+ var criteria = evaluations[0].CriterionScores;
+ for (int idx = 0; idx < criteria.Count; idx++)
+ {
+ scoresSheet.Cell(idx + 2, 1).Value = criteria[idx].CriterionName;
+ scoresSheet.Cell(idx + 2, 2).Value = $"{criteria[idx].Weight:F1}%";
+ for (int e = 0; e < evaluations.Count; e++)
+ {
+ if (idx < evaluations[e].CriterionScores.Count)
+ scoresSheet.Cell(idx + 2, e + 3).Value = evaluations[e].CriterionScores[idx].RawScore;
+ }
+ }
+
+ var totalRow = criteria.Count + 2;
+ scoresSheet.Cell(totalRow, 1).Value = "TOTAL";
+ scoresSheet.Cell(totalRow, 2).Value = "100%";
+ for (int e = 0; e < evaluations.Count; e++)
+ {
+ scoresSheet.Cell(totalRow, e + 3).Value = evaluations[e].TotalScore;
+ }
+ scoresSheet.Row(totalRow).Style.Font.Bold = true;
+ scoresSheet.Columns().AdjustToContents();
+ }
+
+ using var ms = new MemoryStream();
+ workbook.SaveAs(ms);
+ return ms.ToArray();
+ }
+
+ private static string EscapeCsv(string value)
+ {
+ if (string.IsNullOrEmpty(value)) return "";
+ if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
+ {
+ return $"\"{value.Replace("\"", "\"\"")}\"";
+ }
+ return value;
+ }
+
+ private static string CleanJsonResponse(string text)
+ {
+ text = text.Trim();
+ if (text.StartsWith("```json")) text = text[7..];
+ else if (text.StartsWith("```")) text = text[3..];
+ if (text.EndsWith("```")) text = text[..^3];
+ return text.Trim();
+ }
+
+ private async Task CallAzureOpenAIAsync(string systemInstructions, string userPrompt, string reasoningEffort, CancellationToken ct)
+ {
+ var endpoint = _configuration["AZURE_OPENAI_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not configured");
+ var deploymentName = _configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not configured");
+
+ var client = _httpClientFactory.CreateClient();
+ var token = await GetTokenAsync(ct);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ var url = $"{endpoint.TrimEnd('/')}/openai/deployments/{deploymentName}/chat/completions?api-version=2025-01-01-preview";
+
+ var requestBody = new
+ {
+ messages = new object[]
+ {
+ new { role = "system", content = systemInstructions },
+ new { role = "user", content = userPrompt }
+ },
+ reasoning_effort = reasoningEffort
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+ using var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await client.PostAsync(url, httpContent, ct);
+ response.EnsureSuccessStatusCode();
+
+ var responseJson = await response.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(responseJson);
+
+ var choices = doc.RootElement.GetProperty("choices");
+ if (choices.GetArrayLength() > 0)
+ {
+ return choices[0].GetProperty("message").GetProperty("content").GetString() ?? "";
+ }
+
+ return "";
+ }
+
+ private async Task GetTokenAsync(CancellationToken ct)
+ {
+ var tokenResult = await _credential.GetTokenAsync(
+ new TokenRequestContext(new[] { "https://cognitiveservices.azure.com/.default" }), ct);
+ return tokenResult.Token;
+ }
+}
diff --git a/EfpAnalyzer/EfpAnalyzer/Services/DocumentProcessorService.cs b/EfpAnalyzer/EfpAnalyzer/Services/DocumentProcessorService.cs
new file mode 100644
index 0000000..7fc06c0
--- /dev/null
+++ b/EfpAnalyzer/EfpAnalyzer/Services/DocumentProcessorService.cs
@@ -0,0 +1,185 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Azure.Core;
+using Azure.Identity;
+using EfpAnalyzer.Models;
+
+namespace EfpAnalyzer.Services;
+
+public class DocumentProcessorService
+{
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger _logger;
+ private readonly TokenCredential _credential;
+
+ public DocumentProcessorService(
+ IHttpClientFactory httpClientFactory,
+ IConfiguration configuration,
+ ILogger logger)
+ {
+ _httpClientFactory = httpClientFactory;
+ _configuration = configuration;
+ _logger = logger;
+ _credential = new DefaultAzureCredential();
+ }
+
+ public async Task ExtractContentAsync(byte[] fileBytes, string filename, ExtractionService service, CancellationToken ct = default)
+ {
+ var requestId = Guid.NewGuid().ToString()[..8];
+ _logger.LogInformation("[REQ:{RequestId}] Starting content extraction for: {Filename} ({Size} bytes) using {Service}",
+ requestId, filename, fileBytes.Length, service);
+
+ var extension = Path.GetExtension(filename).TrimStart('.').ToLowerInvariant();
+
+ // Handle plain text and markdown files directly
+ if (extension is "txt" or "md")
+ {
+ _logger.LogInformation("[REQ:{RequestId}] Processing as plain text/markdown", requestId);
+ return Encoding.UTF8.GetString(fileBytes);
+ }
+
+ return service switch
+ {
+ ExtractionService.ContentUnderstanding => await ExtractWithContentUnderstandingAsync(fileBytes, requestId, ct),
+ ExtractionService.DocumentIntelligence => await ExtractWithDocumentIntelligenceAsync(fileBytes, requestId, ct),
+ _ => throw new ArgumentOutOfRangeException(nameof(service))
+ };
+ }
+
+ private async Task ExtractWithContentUnderstandingAsync(byte[] fileBytes, string requestId, CancellationToken ct)
+ {
+ var endpoint = _configuration["AZURE_CONTENT_UNDERSTANDING_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_CONTENT_UNDERSTANDING_ENDPOINT is not configured");
+
+ _logger.LogInformation("[REQ:{RequestId}] Processing with Azure Content Understanding...", requestId);
+
+ var client = _httpClientFactory.CreateClient();
+ var token = await GetTokenAsync(ct);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ // Step 1: Begin analysis
+ var analyzeUrl = $"{endpoint.TrimEnd('/')}/contentunderstanding/analyzers/prebuilt-documentSearch:analyze?api-version=2025-11-01";
+
+ using var content = new ByteArrayContent(fileBytes);
+ content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
+
+ var response = await client.PostAsync(analyzeUrl, content, ct);
+ response.EnsureSuccessStatusCode();
+
+ // Step 2: Poll for result
+ var operationLocation = response.Headers.GetValues("Operation-Location").FirstOrDefault()
+ ?? throw new InvalidOperationException("No Operation-Location header in response");
+
+ _logger.LogInformation("[REQ:{RequestId}] Polling for analysis result...", requestId);
+
+ string? markdown = null;
+ for (int i = 0; i < 120; i++) // Poll for up to 10 minutes
+ {
+ await Task.Delay(5000, ct);
+
+ var pollClient = _httpClientFactory.CreateClient();
+ var pollToken = await GetTokenAsync(ct);
+ pollClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pollToken);
+
+ var pollResponse = await pollClient.GetAsync(operationLocation, ct);
+ pollResponse.EnsureSuccessStatusCode();
+
+ var pollJson = await pollResponse.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(pollJson);
+ var status = doc.RootElement.GetProperty("status").GetString();
+
+ if (status == "Succeeded" || status == "succeeded")
+ {
+ if (doc.RootElement.TryGetProperty("result", out var result) &&
+ result.TryGetProperty("contents", out var contents) &&
+ contents.GetArrayLength() > 0)
+ {
+ var first = contents[0];
+ if (first.TryGetProperty("markdown", out var md))
+ {
+ markdown = md.GetString() ?? "";
+ }
+ }
+ break;
+ }
+ else if (status == "Failed" || status == "failed")
+ {
+ throw new InvalidOperationException($"Content Understanding analysis failed: {pollJson}");
+ }
+ }
+
+ _logger.LogInformation("[REQ:{RequestId}] Content Understanding extraction completed ({Chars} chars)", requestId, markdown?.Length ?? 0);
+ return markdown ?? "";
+ }
+
+ private async Task ExtractWithDocumentIntelligenceAsync(byte[] fileBytes, string requestId, CancellationToken ct)
+ {
+ var endpoint = _configuration["AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT"]
+ ?? _configuration["AZURE_CONTENT_UNDERSTANDING_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT is not configured");
+
+ _logger.LogInformation("[REQ:{RequestId}] Processing with Azure Document Intelligence...", requestId);
+
+ var client = _httpClientFactory.CreateClient();
+ var token = await GetTokenAsync(ct);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ // Step 1: Begin analysis
+ var analyzeUrl = $"{endpoint.TrimEnd('/')}/documentintelligence/documentModels/prebuilt-layout:analyze?api-version=2024-11-30&outputContentFormat=markdown";
+
+ using var content = new ByteArrayContent(fileBytes);
+ content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
+
+ var response = await client.PostAsync(analyzeUrl, content, ct);
+ response.EnsureSuccessStatusCode();
+
+ var operationLocation = response.Headers.GetValues("Operation-Location").FirstOrDefault()
+ ?? throw new InvalidOperationException("No Operation-Location header in response");
+
+ _logger.LogInformation("[REQ:{RequestId}] Polling for Document Intelligence result...", requestId);
+
+ string? markdown = null;
+ for (int i = 0; i < 120; i++)
+ {
+ await Task.Delay(5000, ct);
+
+ var pollClient = _httpClientFactory.CreateClient();
+ var pollToken = await GetTokenAsync(ct);
+ pollClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pollToken);
+
+ var pollResponse = await pollClient.GetAsync(operationLocation, ct);
+ pollResponse.EnsureSuccessStatusCode();
+
+ var pollJson = await pollResponse.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(pollJson);
+ var status = doc.RootElement.GetProperty("status").GetString();
+
+ if (status == "succeeded")
+ {
+ if (doc.RootElement.TryGetProperty("analyzeResult", out var analyzeResult) &&
+ analyzeResult.TryGetProperty("content", out var contentProp))
+ {
+ markdown = contentProp.GetString() ?? "";
+ }
+ break;
+ }
+ else if (status == "failed")
+ {
+ throw new InvalidOperationException($"Document Intelligence analysis failed: {pollJson}");
+ }
+ }
+
+ _logger.LogInformation("[REQ:{RequestId}] Document Intelligence extraction completed ({Chars} chars)", requestId, markdown?.Length ?? 0);
+ return markdown ?? "";
+ }
+
+ private async Task GetTokenAsync(CancellationToken ct)
+ {
+ var tokenResult = await _credential.GetTokenAsync(
+ new TokenRequestContext(new[] { "https://cognitiveservices.azure.com/.default" }), ct);
+ return tokenResult.Token;
+ }
+}
diff --git a/EfpAnalyzer/EfpAnalyzer/Services/ScoringService.cs b/EfpAnalyzer/EfpAnalyzer/Services/ScoringService.cs
new file mode 100644
index 0000000..bb801f9
--- /dev/null
+++ b/EfpAnalyzer/EfpAnalyzer/Services/ScoringService.cs
@@ -0,0 +1,513 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using Azure.Core;
+using Azure.Identity;
+using EfpAnalyzer.Models;
+
+namespace EfpAnalyzer.Services;
+
+public class ScoringService
+{
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger _logger;
+ private readonly TokenCredential _credential;
+
+ // The same system instructions from Python's CriteriaExtractionAgent
+ private const string CriteriaExtractionInstructions = """
+ You are an expert procurement analyst specializing in RFP (Request for Proposal) analysis.
+
+ Your task is to carefully analyze RFP documents and extract comprehensive scoring criteria that will be used to evaluate vendor proposals.
+
+ ## YOUR RESPONSIBILITIES:
+
+ 1. **Identify Evaluation Criteria**: Find all evaluation criteria mentioned in the RFP, including:
+ - Explicitly stated criteria (often in "Evaluation Criteria" or "Selection Criteria" sections)
+ - Implied criteria based on requirements and priorities
+ - Industry-standard criteria relevant to the type of work
+
+ 2. **Assign Weights**: Distribute 100 total weight points across criteria based on:
+ - Explicit weights mentioned in the RFP
+ - Emphasis and priority indicated in the document
+ - Industry standards for similar projects
+ - Balanced evaluation across technical, financial, and qualitative factors
+
+ 3. **Provide Evaluation Guidance**: For each criterion, explain:
+ - What constitutes excellent performance (90-100 score)
+ - What constitutes good performance (70-89 score)
+ - What constitutes acceptable performance (50-69 score)
+ - What constitutes poor performance (below 50)
+
+ ## WEIGHT DISTRIBUTION GUIDELINES:
+
+ - Technical capabilities: typically 30-50%
+ - Experience and track record: typically 15-25%
+ - Methodology and approach: typically 15-25%
+ - Pricing/value: typically 15-30% (if mentioned)
+ - Team qualifications: typically 10-20%
+
+ ## OUTPUT REQUIREMENTS:
+
+ You MUST respond with a valid JSON object matching this exact structure:
+
+ ```json
+ {
+ "rfp_title": "Extracted RFP title",
+ "rfp_summary": "2-3 sentence summary of what the RFP is requesting",
+ "total_weight": 100.0,
+ "criteria": [
+ {
+ "criterion_id": "C-1",
+ "name": "Criterion Name",
+ "description": "Detailed description of what this criterion evaluates",
+ "category": "Technical|Financial|Experience|Qualitative",
+ "weight": ,
+ "max_score": 100,
+ "evaluation_guidance": "Detailed guidance on how to score this criterion"
+ }
+ ],
+ "extraction_notes": "Notes about how criteria were identified and weighted"
+ }
+ ```
+
+ ## IMPORTANT:
+ - All weights MUST sum to exactly 100
+ - Include at least 4-8 meaningful criteria
+ - Be specific in descriptions and guidance
+ - Respond with ONLY valid JSON, no additional text
+ """;
+
+ private const string ProposalScoringInstructionsTemplate = """
+ You are an expert procurement evaluator with extensive experience scoring vendor proposals.
+
+ Your task is to objectively evaluate a vendor proposal against specific scoring criteria extracted from an RFP.
+
+ ## SCORING METHODOLOGY:
+
+ For EACH criterion, you must:
+
+ 1. **Find Evidence**: Locate relevant content in the proposal
+ 2. **Assess Quality**: Compare against the evaluation guidance
+ 3. **Assign Score**: Score 0-100 based on:
+ - 90-100 (Excellent): Exceeds requirements, exceptional quality
+ - 70-89 (Good): Fully meets requirements, high quality
+ - 50-69 (Acceptable): Meets minimum requirements
+ - 30-49 (Below Average): Partially meets requirements
+ - 0-29 (Poor): Fails to meet requirements
+
+ 4. **Calculate Weighted Score**: weighted_score = (raw_score * weight) / 100
+
+ 5. **Document Everything**: Provide evidence, justification, strengths, and gaps
+
+ ## EVALUATION CRITERIA TO USE:
+
+ {0}
+
+ ## GRADE ASSIGNMENT:
+
+ Based on total weighted score:
+ - A: 90-100
+ - B: 80-89
+ - C: 70-79
+ - D: 60-69
+ - F: Below 60
+
+ ## OUTPUT FORMAT:
+
+ You MUST respond with a valid JSON object:
+
+ ```json
+ {{
+ "rfp_title": "RFP title",
+ "supplier_name": "Extracted vendor name",
+ "supplier_site": "Vendor location",
+ "response_id": "Generate ID like RESP-2025-XXXX",
+ "evaluation_date": "YYYY-MM-DD",
+ "total_score": ,
+ "score_percentage": ,
+ "grade": "A/B/C/D/F",
+ "recommendation": "Clear recommendation statement",
+ "criterion_scores": [
+ {{
+ "criterion_id": "C-1",
+ "criterion_name": "Criterion Name",
+ "weight": ,
+ "raw_score": <0-100>,
+ "weighted_score": ,
+ "evidence": "Specific evidence from proposal",
+ "justification": "Detailed scoring justification",
+ "strengths": ["strength1", "strength2"],
+ "gaps": ["gap1", "gap2"]
+ }}
+ ],
+ "executive_summary": "2-3 paragraph executive summary",
+ "overall_strengths": ["key strength 1", "key strength 2"],
+ "overall_weaknesses": ["key weakness 1", "key weakness 2"],
+ "recommendations": ["recommendation 1", "recommendation 2"],
+ "risk_assessment": "Assessment of risks with this vendor"
+ }}
+ ```
+
+ ## IMPORTANT:
+ - Score EVERY criterion from the provided list
+ - Provide specific evidence from the proposal
+ - Be objective and fair
+ - Respond with ONLY valid JSON
+ """;
+
+ public ScoringService(
+ IHttpClientFactory httpClientFactory,
+ IConfiguration configuration,
+ ILogger logger)
+ {
+ _httpClientFactory = httpClientFactory;
+ _configuration = configuration;
+ _logger = logger;
+ _credential = new DefaultAzureCredential();
+ }
+
+ public async Task EvaluateAsync(
+ string rfpContent,
+ string proposalContent,
+ string reasoningEffort = "high",
+ Action? progressCallback = null,
+ CancellationToken ct = default)
+ {
+ var totalStart = DateTime.UtcNow;
+ _logger.LogInformation("V2 Multi-Agent evaluation started (effort: {Effort})", reasoningEffort);
+
+ // Phase 1: Extract criteria
+ progressCallback?.Invoke("Phase 1: Extracting scoring criteria from RFP...");
+ var phase1Start = DateTime.UtcNow;
+ var criteria = await ExtractCriteriaAsync(rfpContent, reasoningEffort, progressCallback, ct);
+ var phase1Duration = (DateTime.UtcNow - phase1Start).TotalSeconds;
+ _logger.LogInformation("Phase 1 completed in {Duration:F2}s - Extracted {Count} criteria",
+ phase1Duration, criteria.Criteria.Count);
+
+ // Phase 2: Score proposal
+ progressCallback?.Invoke("Phase 2: Scoring proposal against extracted criteria...");
+ var phase2Start = DateTime.UtcNow;
+ var evaluation = await ScoreProposalAsync(criteria, proposalContent, reasoningEffort, progressCallback, ct);
+ var phase2Duration = (DateTime.UtcNow - phase2Start).TotalSeconds;
+ _logger.LogInformation("Phase 2 completed in {Duration:F2}s - Total score: {Score:F2}",
+ phase2Duration, evaluation.TotalScore);
+
+ var totalDuration = (DateTime.UtcNow - totalStart).TotalSeconds;
+
+ // Build result
+ var result = new EvaluationResult
+ {
+ RfpTitle = evaluation.RfpTitle,
+ SupplierName = evaluation.SupplierName,
+ SupplierSite = evaluation.SupplierSite,
+ ResponseId = evaluation.ResponseId,
+ TotalScore = evaluation.TotalScore,
+ ScorePercentage = evaluation.ScorePercentage,
+ Grade = evaluation.Grade,
+ Recommendation = evaluation.Recommendation,
+ ExtractedCriteria = criteria,
+ CriterionScores = evaluation.CriterionScores,
+ ExecutiveSummary = evaluation.ExecutiveSummary,
+ OverallStrengths = evaluation.OverallStrengths,
+ OverallWeaknesses = evaluation.OverallWeaknesses,
+ Recommendations = evaluation.Recommendations,
+ RiskAssessment = evaluation.RiskAssessment,
+ Metadata = new EvaluationMetadata
+ {
+ Version = "2.0",
+ EvaluationType = "multi-agent",
+ EvaluationTimestamp = DateTime.UtcNow.ToString("o"),
+ TotalDurationSeconds = Math.Round(totalDuration, 2),
+ Phase1CriteriaExtractionSeconds = Math.Round(phase1Duration, 2),
+ Phase2ProposalScoringSeconds = Math.Round(phase2Duration, 2),
+ CriteriaCount = criteria.Criteria.Count,
+ ModelDeployment = _configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? "",
+ ReasoningEffort = reasoningEffort
+ }
+ };
+
+ _logger.LogInformation("V2 Multi-Agent evaluation completed in {Duration:F2}s", totalDuration);
+ return result;
+ }
+
+ private async Task ExtractCriteriaAsync(
+ string rfpContent, string reasoningEffort, Action? progressCallback, CancellationToken ct)
+ {
+ progressCallback?.Invoke("Analyzing RFP structure and requirements...");
+
+ var userPrompt = $"""
+ Please analyze the following RFP document and extract comprehensive scoring criteria.
+
+ ## RFP DOCUMENT:
+
+ {rfpContent}
+
+ ---
+
+ REQUIREMENTS:
+ 1. Identify all evaluation criteria (explicit and implied)
+ 2. Assign weights that sum to exactly 100
+ 3. Provide detailed evaluation guidance for each criterion
+ 4. Include the RFP title and a brief summary
+
+ Respond with ONLY valid JSON matching the schema in your instructions.
+ """;
+
+ progressCallback?.Invoke("Extracting and analyzing criteria...");
+ var responseText = await CallAzureOpenAIAsync(CriteriaExtractionInstructions, userPrompt, reasoningEffort, ct);
+
+ return ParseCriteriaResponse(responseText);
+ }
+
+ private async Task ScoreProposalAsync(
+ ExtractedCriteria criteria, string proposalContent, string reasoningEffort,
+ Action? progressCallback, CancellationToken ct)
+ {
+ progressCallback?.Invoke("Preparing scoring framework...");
+
+ var criteriaJson = JsonSerializer.Serialize(criteria.Criteria.Select(c => new
+ {
+ criterion_id = c.CriterionId,
+ name = c.Name,
+ description = c.Description,
+ category = c.Category,
+ weight = c.Weight,
+ max_score = c.MaxScore,
+ evaluation_guidance = c.EvaluationGuidance
+ }), new JsonSerializerOptions { WriteIndented = true });
+
+ var systemInstructions = string.Format(ProposalScoringInstructionsTemplate, criteriaJson);
+
+ var userPrompt = $"""
+ Please evaluate the following vendor proposal against the scoring criteria.
+
+ ## RFP CONTEXT:
+ - Title: {criteria.RfpTitle}
+ - Summary: {criteria.RfpSummary}
+
+ ## VENDOR PROPOSAL:
+
+ {proposalContent}
+
+ ---
+
+ REQUIREMENTS:
+ 1. Score each criterion from 0-100
+ 2. Calculate weighted scores
+ 3. Provide evidence and justification for each score
+ 4. Summarize strengths, weaknesses, and recommendations
+ 5. Assign an overall grade
+
+ Respond with ONLY valid JSON matching the schema in your instructions.
+ """;
+
+ progressCallback?.Invoke("Scoring proposal against criteria...");
+ var responseText = await CallAzureOpenAIAsync(systemInstructions, userPrompt, reasoningEffort, ct);
+
+ return ParseScoringResponse(responseText, criteria);
+ }
+
+ private async Task CallAzureOpenAIAsync(string systemInstructions, string userPrompt, string reasoningEffort, CancellationToken ct)
+ {
+ var endpoint = _configuration["AZURE_OPENAI_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not configured");
+ var deploymentName = _configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not configured");
+
+ var client = _httpClientFactory.CreateClient();
+ var token = await GetTokenAsync(ct);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ // Use the responses API endpoint (compatible with agent-framework)
+ var url = $"{endpoint.TrimEnd('/')}/openai/deployments/{deploymentName}/chat/completions?api-version=2025-01-01-preview";
+
+ var requestBody = new
+ {
+ messages = new object[]
+ {
+ new { role = "system", content = systemInstructions },
+ new { role = "user", content = userPrompt }
+ },
+ reasoning_effort = reasoningEffort
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+ using var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await client.PostAsync(url, httpContent, ct);
+ response.EnsureSuccessStatusCode();
+
+ var responseJson = await response.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(responseJson);
+
+ var choices = doc.RootElement.GetProperty("choices");
+ if (choices.GetArrayLength() > 0)
+ {
+ return choices[0].GetProperty("message").GetProperty("content").GetString() ?? "";
+ }
+
+ return "";
+ }
+
+ private ExtractedCriteria ParseCriteriaResponse(string responseText)
+ {
+ var text = CleanJsonResponse(responseText);
+
+ try
+ {
+ using var doc = JsonDocument.Parse(text);
+ var root = doc.RootElement;
+
+ var criteria = new ExtractedCriteria
+ {
+ RfpTitle = root.GetProperty("rfp_title").GetString() ?? "Unknown RFP",
+ RfpSummary = root.GetProperty("rfp_summary").GetString() ?? "",
+ ExtractionNotes = root.TryGetProperty("extraction_notes", out var notes) ? notes.GetString() ?? "" : ""
+ };
+
+ if (root.TryGetProperty("criteria", out var criteriaArray))
+ {
+ foreach (var c in criteriaArray.EnumerateArray())
+ {
+ criteria.Criteria.Add(new ScoringCriterion
+ {
+ CriterionId = c.GetProperty("criterion_id").GetString() ?? "",
+ Name = c.GetProperty("name").GetString() ?? "",
+ Description = c.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "",
+ Category = c.TryGetProperty("category", out var cat) ? cat.GetString() ?? "" : "",
+ Weight = c.GetProperty("weight").GetDouble(),
+ MaxScore = c.TryGetProperty("max_score", out var ms) ? ms.GetInt32() : 100,
+ EvaluationGuidance = c.TryGetProperty("evaluation_guidance", out var eg) ? eg.GetString() ?? "" : ""
+ });
+ }
+ }
+
+ // Normalize weights
+ var totalWeight = criteria.Criteria.Sum(c => c.Weight);
+ if (criteria.Criteria.Count > 0 && Math.Abs(totalWeight - 100) > 0.1)
+ {
+ _logger.LogWarning("Normalizing weights from {Total} to 100", totalWeight);
+ foreach (var c in criteria.Criteria)
+ {
+ c.Weight = c.Weight / totalWeight * 100;
+ }
+ }
+ criteria.TotalWeight = 100.0;
+
+ return criteria;
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex, "Failed to parse criteria JSON");
+ return new ExtractedCriteria
+ {
+ RfpTitle = "Unknown RFP",
+ RfpSummary = "Failed to extract RFP summary",
+ ExtractionNotes = $"Error parsing response: {ex.Message}"
+ };
+ }
+ }
+
+ private ProposalEvaluation ParseScoringResponse(string responseText, ExtractedCriteria criteria)
+ {
+ var text = CleanJsonResponse(responseText);
+
+ try
+ {
+ using var doc = JsonDocument.Parse(text);
+ var root = doc.RootElement;
+
+ var evaluation = new ProposalEvaluation
+ {
+ RfpTitle = root.TryGetProperty("rfp_title", out var rt) ? rt.GetString() ?? "" : criteria.RfpTitle,
+ SupplierName = root.TryGetProperty("supplier_name", out var sn) ? sn.GetString() ?? "Unknown Vendor" : "Unknown Vendor",
+ SupplierSite = root.TryGetProperty("supplier_site", out var ss) ? ss.GetString() ?? "" : "",
+ ResponseId = root.TryGetProperty("response_id", out var ri) ? ri.GetString() ?? "" : "",
+ EvaluationDate = root.TryGetProperty("evaluation_date", out var ed) ? ed.GetString() ?? DateTime.UtcNow.ToString("yyyy-MM-dd") : DateTime.UtcNow.ToString("yyyy-MM-dd"),
+ Recommendation = root.TryGetProperty("recommendation", out var rec) ? rec.GetString() ?? "" : "",
+ ExecutiveSummary = root.TryGetProperty("executive_summary", out var es) ? es.GetString() ?? "" : "",
+ RiskAssessment = root.TryGetProperty("risk_assessment", out var ra) ? ra.GetString() ?? "" : ""
+ };
+
+ // Parse criterion scores
+ if (root.TryGetProperty("criterion_scores", out var scoresArray))
+ {
+ foreach (var cs in scoresArray.EnumerateArray())
+ {
+ evaluation.CriterionScores.Add(new CriterionScore
+ {
+ CriterionId = cs.TryGetProperty("criterion_id", out var cid) ? cid.GetString() ?? "" : "",
+ CriterionName = cs.TryGetProperty("criterion_name", out var cn) ? cn.GetString() ?? "" : "",
+ Weight = cs.TryGetProperty("weight", out var w) ? w.GetDouble() : 0,
+ RawScore = cs.TryGetProperty("raw_score", out var rs) ? rs.GetDouble() : 0,
+ WeightedScore = cs.TryGetProperty("weighted_score", out var ws) ? ws.GetDouble() : 0,
+ Evidence = cs.TryGetProperty("evidence", out var ev) ? ev.GetString() ?? "" : "",
+ Justification = cs.TryGetProperty("justification", out var j) ? j.GetString() ?? "" : "",
+ Strengths = cs.TryGetProperty("strengths", out var str) ? str.EnumerateArray().Select(s => s.GetString() ?? "").ToList() : new(),
+ Gaps = cs.TryGetProperty("gaps", out var g) ? g.EnumerateArray().Select(s => s.GetString() ?? "").ToList() : new()
+ });
+ }
+ }
+
+ // Parse string arrays
+ evaluation.OverallStrengths = ParseStringArray(root, "overall_strengths");
+ evaluation.OverallWeaknesses = ParseStringArray(root, "overall_weaknesses");
+ evaluation.Recommendations = ParseStringArray(root, "recommendations");
+
+ // Recalculate total score for accuracy
+ var totalScore = evaluation.CriterionScores.Sum(cs => cs.WeightedScore);
+ evaluation.TotalScore = Math.Round(totalScore, 2);
+ evaluation.ScorePercentage = Math.Round(totalScore, 2);
+
+ // Determine grade
+ evaluation.Grade = totalScore switch
+ {
+ >= 90 => "A",
+ >= 80 => "B",
+ >= 70 => "C",
+ >= 60 => "D",
+ _ => "F"
+ };
+
+ return evaluation;
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex, "Failed to parse scoring JSON");
+ return new ProposalEvaluation
+ {
+ RfpTitle = criteria.RfpTitle,
+ SupplierName = "Unknown Vendor",
+ EvaluationDate = DateTime.UtcNow.ToString("yyyy-MM-dd"),
+ Grade = "F",
+ Recommendation = "Unable to complete evaluation due to parsing error"
+ };
+ }
+ }
+
+ private static string CleanJsonResponse(string text)
+ {
+ text = text.Trim();
+ if (text.StartsWith("```json")) text = text[7..];
+ else if (text.StartsWith("```")) text = text[3..];
+ if (text.EndsWith("```")) text = text[..^3];
+ return text.Trim();
+ }
+
+ private static List ParseStringArray(JsonElement root, string propertyName)
+ {
+ if (root.TryGetProperty(propertyName, out var array))
+ {
+ return array.EnumerateArray().Select(s => s.GetString() ?? "").ToList();
+ }
+ return new();
+ }
+
+ private async Task GetTokenAsync(CancellationToken ct)
+ {
+ var tokenResult = await _credential.GetTokenAsync(
+ new TokenRequestContext(new[] { "https://cognitiveservices.azure.com/.default" }), ct);
+ return tokenResult.Token;
+ }
+}
From b0a60d5433a2093658eede2deb45f492a41937b3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 19:25:14 +0000
Subject: [PATCH 04/17] Create Blazor UI components for EFP Analyzer 3-step
workflow
Co-authored-by: amgdy <1763289+amgdy@users.noreply.github.com>
---
EfpAnalyzer/EfpAnalyzer/Components/App.razor | 25 +-
.../Components/Layout/MainLayout.razor | 64 ++-
.../Components/Layout/MainLayout.razor.css | 98 -----
.../Components/Layout/NavMenu.razor | 35 +-
.../Components/Layout/NavMenu.razor.css | 105 -----
.../Components/Pages/Counter.razor | 19 -
.../Components/Pages/Evaluate.razor | 397 ++++++++++++++++++
.../Components/Pages/Extract.razor | 157 +++++++
.../EfpAnalyzer/Components/Pages/Home.razor | 96 ++++-
.../EfpAnalyzer/Components/Pages/Upload.razor | 140 ++++++
.../Components/Pages/Weather.razor | 64 ---
.../EfpAnalyzer/Components/_Imports.razor | 5 +-
EfpAnalyzer/EfpAnalyzer/Program.cs | 23 +-
EfpAnalyzer/EfpAnalyzer/wwwroot/app.css | 55 +--
14 files changed, 881 insertions(+), 402 deletions(-)
delete mode 100644 EfpAnalyzer/EfpAnalyzer/Components/Layout/MainLayout.razor.css
delete mode 100644 EfpAnalyzer/EfpAnalyzer/Components/Layout/NavMenu.razor.css
delete mode 100644 EfpAnalyzer/EfpAnalyzer/Components/Pages/Counter.razor
create mode 100644 EfpAnalyzer/EfpAnalyzer/Components/Pages/Evaluate.razor
create mode 100644 EfpAnalyzer/EfpAnalyzer/Components/Pages/Extract.razor
create mode 100644 EfpAnalyzer/EfpAnalyzer/Components/Pages/Upload.razor
delete mode 100644 EfpAnalyzer/EfpAnalyzer/Components/Pages/Weather.razor
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/App.razor b/EfpAnalyzer/EfpAnalyzer/Components/App.razor
index 7ff2091..05d3925 100644
--- a/EfpAnalyzer/EfpAnalyzer/Components/App.razor
+++ b/EfpAnalyzer/EfpAnalyzer/Components/App.razor
@@ -1,23 +1,30 @@
-
+
-
-
-
+
+
-
+
-
-
-
+
+
+
-
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/Layout/MainLayout.razor b/EfpAnalyzer/EfpAnalyzer/Components/Layout/MainLayout.razor
index 78624f3..d2d7fa0 100644
--- a/EfpAnalyzer/EfpAnalyzer/Components/Layout/MainLayout.razor
+++ b/EfpAnalyzer/EfpAnalyzer/Components/Layout/MainLayout.razor
@@ -1,23 +1,51 @@
-@inherits LayoutComponentBase
+@inherits LayoutComponentBase
+@inject ILogger Logger
-
-
+
+
+
+ @Body
+
+
+
-
-
+@code {
+ private bool _drawerOpen = true;
+ private bool _isDarkMode = false;
-
- @Body
-
-
-
+ private MudTheme _theme = new()
+ {
+ PaletteLight = new PaletteLight
+ {
+ Primary = "#1976D2",
+ Secondary = "#424242",
+ AppbarBackground = "#1976D2"
+ },
+ PaletteDark = new PaletteDark
+ {
+ Primary = "#90CAF9",
+ Secondary = "#CE93D8",
+ AppbarBackground = "#1E1E2E"
+ }
+ };
-
- An unhandled error has occurred.
-
Reload
-
🗙
-
+ private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
+}
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/Layout/MainLayout.razor.css b/EfpAnalyzer/EfpAnalyzer/Components/Layout/MainLayout.razor.css
deleted file mode 100644
index 38d1f25..0000000
--- a/EfpAnalyzer/EfpAnalyzer/Components/Layout/MainLayout.razor.css
+++ /dev/null
@@ -1,98 +0,0 @@
-.page {
- position: relative;
- display: flex;
- flex-direction: column;
-}
-
-main {
- flex: 1;
-}
-
-.sidebar {
- background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
-}
-
-.top-row {
- background-color: #f7f7f7;
- border-bottom: 1px solid #d6d5d5;
- justify-content: flex-end;
- height: 3.5rem;
- display: flex;
- align-items: center;
-}
-
- .top-row ::deep a, .top-row ::deep .btn-link {
- white-space: nowrap;
- margin-left: 1.5rem;
- text-decoration: none;
- }
-
- .top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
- text-decoration: underline;
- }
-
- .top-row ::deep a:first-child {
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
-@media (max-width: 640.98px) {
- .top-row {
- justify-content: space-between;
- }
-
- .top-row ::deep a, .top-row ::deep .btn-link {
- margin-left: 0;
- }
-}
-
-@media (min-width: 641px) {
- .page {
- flex-direction: row;
- }
-
- .sidebar {
- width: 250px;
- height: 100vh;
- position: sticky;
- top: 0;
- }
-
- .top-row {
- position: sticky;
- top: 0;
- z-index: 1;
- }
-
- .top-row.auth ::deep a:first-child {
- flex: 1;
- text-align: right;
- width: 0;
- }
-
- .top-row, article {
- padding-left: 2rem !important;
- padding-right: 1.5rem !important;
- }
-}
-
-#blazor-error-ui {
- color-scheme: light only;
- background: lightyellow;
- bottom: 0;
- box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
- box-sizing: border-box;
- display: none;
- left: 0;
- padding: 0.6rem 1.25rem 0.7rem 1.25rem;
- position: fixed;
- width: 100%;
- z-index: 1000;
-}
-
- #blazor-error-ui .dismiss {
- cursor: pointer;
- position: absolute;
- right: 0.75rem;
- top: 0.5rem;
- }
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/Layout/NavMenu.razor b/EfpAnalyzer/EfpAnalyzer/Components/Layout/NavMenu.razor
index a853e98..f4c786b 100644
--- a/EfpAnalyzer/EfpAnalyzer/Components/Layout/NavMenu.razor
+++ b/EfpAnalyzer/EfpAnalyzer/Components/Layout/NavMenu.razor
@@ -1,30 +1,7 @@
-
-
-
-
-
+
+ Home
+ 1. Upload Documents
+ 2. Extract Content
+ 3. Evaluate & Compare
+
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/Layout/NavMenu.razor.css b/EfpAnalyzer/EfpAnalyzer/Components/Layout/NavMenu.razor.css
deleted file mode 100644
index a2aeace..0000000
--- a/EfpAnalyzer/EfpAnalyzer/Components/Layout/NavMenu.razor.css
+++ /dev/null
@@ -1,105 +0,0 @@
-.navbar-toggler {
- appearance: none;
- cursor: pointer;
- width: 3.5rem;
- height: 2.5rem;
- color: white;
- position: absolute;
- top: 0.5rem;
- right: 1rem;
- border: 1px solid rgba(255, 255, 255, 0.1);
- background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
-}
-
-.navbar-toggler:checked {
- background-color: rgba(255, 255, 255, 0.5);
-}
-
-.top-row {
- min-height: 3.5rem;
- background-color: rgba(0,0,0,0.4);
-}
-
-.navbar-brand {
- font-size: 1.1rem;
-}
-
-.bi {
- display: inline-block;
- position: relative;
- width: 1.25rem;
- height: 1.25rem;
- margin-right: 0.75rem;
- top: -1px;
- background-size: cover;
-}
-
-.bi-house-door-fill-nav-menu {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
-}
-
-.bi-plus-square-fill-nav-menu {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
-}
-
-.bi-list-nested-nav-menu {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
-}
-
-.nav-item {
- font-size: 0.9rem;
- padding-bottom: 0.5rem;
-}
-
- .nav-item:first-of-type {
- padding-top: 1rem;
- }
-
- .nav-item:last-of-type {
- padding-bottom: 1rem;
- }
-
- .nav-item ::deep .nav-link {
- color: #d7d7d7;
- background: none;
- border: none;
- border-radius: 4px;
- height: 3rem;
- display: flex;
- align-items: center;
- line-height: 3rem;
- width: 100%;
- }
-
-.nav-item ::deep a.active {
- background-color: rgba(255,255,255,0.37);
- color: white;
-}
-
-.nav-item ::deep .nav-link:hover {
- background-color: rgba(255,255,255,0.1);
- color: white;
-}
-
-.nav-scrollable {
- display: none;
-}
-
-.navbar-toggler:checked ~ .nav-scrollable {
- display: block;
-}
-
-@media (min-width: 641px) {
- .navbar-toggler {
- display: none;
- }
-
- .nav-scrollable {
- /* Never collapse the sidebar for wide screens */
- display: block;
-
- /* Allow sidebar to scroll for tall menus */
- height: calc(100vh - 3.5rem);
- overflow-y: auto;
- }
-}
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/Pages/Counter.razor b/EfpAnalyzer/EfpAnalyzer/Components/Pages/Counter.razor
deleted file mode 100644
index 1a4f8e7..0000000
--- a/EfpAnalyzer/EfpAnalyzer/Components/Pages/Counter.razor
+++ /dev/null
@@ -1,19 +0,0 @@
-@page "/counter"
-@rendermode InteractiveServer
-
-Counter
-
-Counter
-
-Current count: @currentCount
-
-Click me
-
-@code {
- private int currentCount = 0;
-
- private void IncrementCount()
- {
- currentCount++;
- }
-}
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/Pages/Evaluate.razor b/EfpAnalyzer/EfpAnalyzer/Components/Pages/Evaluate.razor
new file mode 100644
index 0000000..741cb33
--- /dev/null
+++ b/EfpAnalyzer/EfpAnalyzer/Components/Pages/Evaluate.razor
@@ -0,0 +1,397 @@
+@page "/evaluate"
+@using EfpAnalyzer.Models
+@using System.Text.Json
+@inject AppState AppState
+@inject ScoringService ScoringService
+@inject ComparisonService ComparisonService
+@inject ISnackbar Snackbar
+@inject IJSRuntime JS
+@rendermode InteractiveServer
+
+Evaluate & Compare - EFP Analyzer
+
+Step 3: Evaluate & Compare
+
+@if (AppState.RfpDocument?.IsExtracted != true)
+{
+ Please extract documents first.
+ Go to Extract
+ return;
+}
+
+@* Evaluate Button *@
+@if (!_isEvaluating && AppState.EvaluationResults.Count == 0)
+{
+
+ Ready to Evaluate
+
+ @AppState.ProposalDocuments.Count proposal(s) ready for evaluation against the RFP.
+ Reasoning effort: @AppState.ReasoningEffort.
+
+
+ Start Evaluation
+
+
+}
+
+@* Progress *@
+@if (_isEvaluating)
+{
+
+ Evaluating Proposals...
+
+ @_currentStatus
+
+}
+
+@* Results *@
+@if (AppState.EvaluationResults.Count > 0)
+{
+ @* Score Overview Cards *@
+ Evaluation Results
+
+ @foreach (var kvp in AppState.EvaluationResults)
+ {
+ var result = kvp.Value;
+
+
+
+ @result.SupplierName
+
+
+
+
+ Total Score
+
+ @result.TotalScore.ToString("F1")
+
+
+
+ Grade
+
+ @result.Grade
+
+
+
+ @if (!string.IsNullOrEmpty(result.Recommendation))
+ {
+ @result.Recommendation
+ }
+
+
+
+ }
+
+
+ @* Detailed Results Tabs *@
+
+ @foreach (var kvp in AppState.EvaluationResults)
+ {
+ var result = kvp.Value;
+
+ @* Criterion Scores Data Grid *@
+ Criterion Scores
+
+
+
+
+
+
+
+
+
+ @* Criterion Detail Expansion Panels *@
+ Detailed Analysis
+
+ @foreach (var cs in result.CriterionScores)
+ {
+
+ Justification
+ @cs.Justification
+
+ @if (!string.IsNullOrEmpty(cs.Evidence))
+ {
+ Evidence
+ @cs.Evidence
+ }
+
+ @if (cs.Strengths.Count > 0)
+ {
+ Strengths
+
+ @foreach (var s in cs.Strengths)
+ {
+ @s
+ }
+
+ }
+
+ @if (cs.Gaps.Count > 0)
+ {
+ Gaps
+
+ @foreach (var g in cs.Gaps)
+ {
+ @g
+ }
+
+ }
+
+ }
+
+
+ @* Executive Summary *@
+ @if (!string.IsNullOrEmpty(result.ExecutiveSummary))
+ {
+ Executive Summary
+ @result.ExecutiveSummary
+ }
+
+ @* Strengths & Weaknesses *@
+
+
+ Strengths
+
+ @foreach (var s in result.OverallStrengths)
+ {
+ @s
+ }
+
+
+
+ Weaknesses
+
+ @foreach (var w in result.OverallWeaknesses)
+ {
+ @w
+ }
+
+
+
+
+ @* Risk Assessment *@
+ @if (!string.IsNullOrEmpty(result.RiskAssessment))
+ {
+ Risk Assessment
+ @result.RiskAssessment
+ }
+
+ }
+
+
+ @* Comparison Section *@
+ @if (AppState.EvaluationResults.Count > 1)
+ {
+
+ Vendor Comparison
+
+ @if (AppState.ComparisonResult == null && !_isComparing)
+ {
+
+ Compare Vendors
+
+ }
+
+ @if (_isComparing)
+ {
+
+ @_comparisonStatus
+ }
+
+ @if (AppState.ComparisonResult != null)
+ {
+ var comp = AppState.ComparisonResult;
+
+ @* Rankings *@
+ Rankings
+
+
+
+
+
+
+
+
+
+
+ @* Winner Summary *@
+ @if (!string.IsNullOrEmpty(comp.WinnerSummary))
+ {
+
+ Winner Summary
+ @comp.WinnerSummary
+
+ }
+
+ @* Selection Recommendation *@
+ @if (!string.IsNullOrEmpty(comp.SelectionRecommendation))
+ {
+ Selection Recommendation
+ @comp.SelectionRecommendation
+ }
+
+ @* Criterion Comparisons *@
+ @if (comp.CriterionComparisons.Count > 0)
+ {
+ Performance by Criterion
+
+
+
+
+
+
+
+
+
+
+ }
+
+ @* Key Insights *@
+ @if (comp.ComparisonInsights.Count > 0)
+ {
+ Key Insights
+
+ @foreach (var insight in comp.ComparisonInsights)
+ {
+ @insight
+ }
+
+ }
+ }
+ }
+
+ @* Export Buttons *@
+
+ Export Results
+
+ JSON
+ CSV
+ Excel
+
+}
+
+@* Navigation *@
+
+ Back to Extract
+
+
+@code {
+ private bool _isEvaluating = false;
+ private bool _isComparing = false;
+ private string _currentStatus = "";
+ private string _comparisonStatus = "";
+
+ private async Task EvaluateAll()
+ {
+ _isEvaluating = true;
+ try
+ {
+ foreach (var doc in AppState.ProposalDocuments)
+ {
+ _currentStatus = $"Evaluating: {doc.FileName}...";
+ StateHasChanged();
+
+ var result = await ScoringService.EvaluateAsync(
+ AppState.RfpDocument!.ExtractedContent!,
+ doc.ExtractedContent!,
+ AppState.ReasoningEffort,
+ status => { _currentStatus = status; InvokeAsync(StateHasChanged); });
+
+ AppState.EvaluationResults[doc.FileName] = result;
+ StateHasChanged();
+ }
+ Snackbar.Add("All evaluations completed!", Severity.Success);
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add($"Evaluation error: {ex.Message}", Severity.Error);
+ }
+ finally
+ {
+ _isEvaluating = false;
+ _currentStatus = "";
+ }
+ }
+
+ private async Task RunComparison()
+ {
+ _isComparing = true;
+ try
+ {
+ _comparisonStatus = "Comparing vendor evaluations...";
+ StateHasChanged();
+
+ var evaluations = AppState.EvaluationResults.Values.ToList();
+ var rfpTitle = evaluations.FirstOrDefault()?.RfpTitle ?? "RFP";
+
+ AppState.ComparisonResult = await ComparisonService.CompareEvaluationsAsync(
+ evaluations, rfpTitle, AppState.ReasoningEffort,
+ status => { _comparisonStatus = status; InvokeAsync(StateHasChanged); });
+
+ Snackbar.Add("Comparison completed!", Severity.Success);
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add($"Comparison error: {ex.Message}", Severity.Error);
+ }
+ finally
+ {
+ _isComparing = false;
+ _comparisonStatus = "";
+ }
+ }
+
+ private async Task ExportJson()
+ {
+ var data = new
+ {
+ evaluations = AppState.EvaluationResults,
+ comparison = AppState.ComparisonResult
+ };
+ var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
+ var bytes = System.Text.Encoding.UTF8.GetBytes(json);
+ await DownloadFile("efp_analysis.json", bytes, "application/json");
+ }
+
+ private async Task ExportCsv()
+ {
+ if (AppState.ComparisonResult == null) return;
+ var bytes = ComparisonService.GenerateCsvReport(AppState.ComparisonResult, AppState.EvaluationResults.Values.ToList());
+ await DownloadFile("efp_comparison.csv", bytes, "text/csv");
+ }
+
+ private async Task ExportExcel()
+ {
+ if (AppState.ComparisonResult == null) return;
+ var bytes = ComparisonService.GenerateExcelReport(AppState.ComparisonResult, AppState.EvaluationResults.Values.ToList());
+ await DownloadFile("efp_comparison.xlsx", bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ }
+
+ private async Task DownloadFile(string filename, byte[] content, string contentType)
+ {
+ var base64 = Convert.ToBase64String(content);
+ await JS.InvokeVoidAsync("downloadFile", filename, base64, contentType);
+ }
+
+ private static Color GetScoreColor(double score) => score switch
+ {
+ >= 90 => Color.Success,
+ >= 70 => Color.Info,
+ >= 50 => Color.Warning,
+ _ => Color.Error
+ };
+
+ private static Color GetGradeColor(string grade) => grade switch
+ {
+ "A" => Color.Success,
+ "B" => Color.Info,
+ "C" => Color.Warning,
+ "D" => Color.Error,
+ _ => Color.Error
+ };
+}
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/Pages/Extract.razor b/EfpAnalyzer/EfpAnalyzer/Components/Pages/Extract.razor
new file mode 100644
index 0000000..d387d74
--- /dev/null
+++ b/EfpAnalyzer/EfpAnalyzer/Components/Pages/Extract.razor
@@ -0,0 +1,157 @@
+@page "/extract"
+@inject AppState AppState
+@inject DocumentProcessorService DocumentProcessor
+@inject ISnackbar Snackbar
+@inject NavigationManager Navigation
+@rendermode InteractiveServer
+
+Extract Content - EFP Analyzer
+
+Step 2: Extract & Configure
+Configure extraction settings and process your documents.
+
+@if (AppState.RfpDocument == null)
+{
+ Please upload documents first.
+ Go to Upload
+ return;
+}
+
+
+ @* Configuration *@
+
+
+ Extraction Service
+
+
+ Content Understanding
+
+
+ Document Intelligence
+
+
+
+
+
+ Analysis Depth
+
+ Standard (~5 min)
+ Thorough (~10 min)
+ Comprehensive (~15 min)
+
+
+
+
+
+
+
+
+ @* Extraction Status *@
+
+
+ Document Extraction
+
+ @if (_isExtracting)
+ {
+
+ @_currentStatus
+ }
+
+ @* RFP Status *@
+
+ @(AppState.RfpDocument.IsExtracted ? "✅" : "⏳") RFP: @AppState.RfpDocument.FileName
+ @if (AppState.RfpDocument.IsExtracted)
+ {
+ — @(AppState.RfpDocument.ExtractedContent?.Length ?? 0) chars extracted
+ }
+
+
+ @* Proposal Status *@
+ @foreach (var doc in AppState.ProposalDocuments)
+ {
+
+ @(doc.IsExtracted ? "✅" : "⏳") @doc.FileName
+ @if (doc.IsExtracted)
+ {
+ — @(doc.ExtractedContent?.Length ?? 0) chars extracted
+ }
+
+ }
+
+
+ @(_isExtracting ? "Extracting..." : "Extract Content")
+
+
+
+
+
+@* Navigation *@
+
+
+
+ Back to Upload
+
+
+
+ Next: Evaluate
+
+
+
+
+
+@code {
+ private bool _isExtracting = false;
+ private string _currentStatus = "";
+
+ private bool AllExtracted()
+ {
+ return AppState.RfpDocument?.IsExtracted == true &&
+ AppState.ProposalDocuments.All(p => p.IsExtracted);
+ }
+
+ private async Task ExtractAllDocuments()
+ {
+ _isExtracting = true;
+ try
+ {
+ // Extract RFP
+ if (!AppState.RfpDocument!.IsExtracted)
+ {
+ _currentStatus = $"Extracting RFP: {AppState.RfpDocument.FileName}...";
+ StateHasChanged();
+ AppState.RfpDocument.ExtractedContent = await DocumentProcessor.ExtractContentAsync(
+ AppState.RfpDocument.Content, AppState.RfpDocument.FileName, AppState.SelectedService);
+ AppState.RfpDocument.IsExtracted = true;
+ StateHasChanged();
+ }
+
+ // Extract proposals
+ foreach (var doc in AppState.ProposalDocuments.Where(d => !d.IsExtracted))
+ {
+ _currentStatus = $"Extracting: {doc.FileName}...";
+ StateHasChanged();
+ doc.ExtractedContent = await DocumentProcessor.ExtractContentAsync(
+ doc.Content, doc.FileName, AppState.SelectedService);
+ doc.IsExtracted = true;
+ StateHasChanged();
+ }
+
+ Snackbar.Add("All documents extracted successfully!", Severity.Success);
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add($"Extraction error: {ex.Message}", Severity.Error);
+ }
+ finally
+ {
+ _isExtracting = false;
+ _currentStatus = "";
+ }
+ }
+}
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/Pages/Home.razor b/EfpAnalyzer/EfpAnalyzer/Components/Pages/Home.razor
index 9001e0b..40e4fb6 100644
--- a/EfpAnalyzer/EfpAnalyzer/Components/Pages/Home.razor
+++ b/EfpAnalyzer/EfpAnalyzer/Components/Pages/Home.razor
@@ -1,7 +1,95 @@
-@page "/"
+@page "/"
-Home
+EFP Analyzer
-Hello, world!
+
+ @* Hero Section *@
+
+
+ EFP Analyzer
+
+ AI-Powered Proposal Analysis & Scoring
+
+
+ Upload your RFP and vendor proposals, extract content using Azure AI services,
+ and get intelligent scoring with multi-agent evaluation.
+
+
+ Start Analysis
+
+
-Welcome to your new app.
+ @* Feature Cards *@
+
+
+
+
+
+ Smart Extraction
+
+ Extract content from PDFs, Word docs, and more using Azure AI Document Intelligence
+ or Content Understanding.
+
+
+
+
+
+
+
+
+ Intelligent Scoring
+
+ Multi-agent AI system extracts criteria from RFPs and scores proposals
+ with detailed justifications.
+
+
+
+
+
+
+
+
+ Comparative Analysis
+
+ Compare multiple vendors side by side with rankings, insights,
+ and selection recommendations.
+
+
+
+
+
+
+
+
+ Comprehensive Reports
+
+ Export results in CSV, Excel, and JSON formats for easy sharing
+ and further analysis.
+
+
+
+
+
+
+ @* How It Works *@
+ How It Works
+
+
+ 1. Upload Documents
+ Upload your RFP and one or more vendor proposals (PDF, DOCX, TXT, MD).
+
+
+ 2. Extract Content
+ Azure AI services extract text, tables, and structure from your documents.
+
+
+ 3. AI Evaluation
+ Multi-agent AI extracts criteria and scores each proposal with evidence.
+
+
+ 4. Compare & Export
+ Compare vendors, review rankings, and export comprehensive reports.
+
+
+
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/Pages/Upload.razor b/EfpAnalyzer/EfpAnalyzer/Components/Pages/Upload.razor
new file mode 100644
index 0000000..7496857
--- /dev/null
+++ b/EfpAnalyzer/EfpAnalyzer/Components/Pages/Upload.razor
@@ -0,0 +1,140 @@
+@page "/upload"
+@inject AppState AppState
+@inject ISnackbar Snackbar
+@inject NavigationManager Navigation
+
+Upload Documents - EFP Analyzer
+
+Step 1: Upload Documents
+Upload your RFP document and vendor proposals for analysis.
+
+
+ @* RFP Document Upload *@
+
+
+
+ RFP Document
+
+ Upload the Request for Proposal document.
+
+
+
+
+ Upload RFP
+
+
+
+
+ @if (AppState.RfpDocument != null)
+ {
+
+ @AppState.RfpDocument.FileName
+ Size: @FormatFileSize(AppState.RfpDocument.Size)
+
+ }
+
+
+
+ @* Vendor Proposals Upload *@
+
+
+
+ Vendor Proposals
+
+ Upload one or more vendor proposals.
+
+
+
+
+ Upload Proposals
+
+
+
+
+ @if (AppState.ProposalDocuments.Count > 0)
+ {
+
+ @foreach (var doc in AppState.ProposalDocuments)
+ {
+
+ @doc.FileName (@FormatFileSize(doc.Size))
+
+ }
+
+ }
+
+
+
+
+@* Navigation *@
+
+
+
+ Back to Home
+
+
+
+ Next: Extract Content
+
+
+
+
+
+@code {
+ private async Task OnRfpFileChanged(IBrowserFile file)
+ {
+ try
+ {
+ var buffer = new byte[file.Size];
+ await using var stream = file.OpenReadStream(maxAllowedSize: 500 * 1024 * 1024);
+ await stream.ReadExactlyAsync(buffer);
+
+ AppState.RfpDocument = new UploadedDocument
+ {
+ FileName = file.Name,
+ Content = buffer,
+ Size = file.Size
+ };
+ Snackbar.Add($"RFP uploaded: {file.Name}", Severity.Success);
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add($"Error uploading RFP: {ex.Message}", Severity.Error);
+ }
+ }
+
+ private async Task OnProposalFilesChanged(IReadOnlyList files)
+ {
+ foreach (var file in files)
+ {
+ try
+ {
+ var buffer = new byte[file.Size];
+ await using var stream = file.OpenReadStream(maxAllowedSize: 500 * 1024 * 1024);
+ await stream.ReadExactlyAsync(buffer);
+
+ AppState.ProposalDocuments.Add(new UploadedDocument
+ {
+ FileName = file.Name,
+ Content = buffer,
+ Size = file.Size
+ });
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add($"Error uploading {file.Name}: {ex.Message}", Severity.Error);
+ }
+ }
+ if (files.Count > 0)
+ Snackbar.Add($"{files.Count} proposal(s) uploaded", Severity.Success);
+ }
+
+ private static string FormatFileSize(long bytes)
+ {
+ if (bytes < 1024) return $"{bytes} B";
+ if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
+ return $"{bytes / (1024.0 * 1024.0):F1} MB";
+ }
+}
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/Pages/Weather.razor b/EfpAnalyzer/EfpAnalyzer/Components/Pages/Weather.razor
deleted file mode 100644
index f437e5e..0000000
--- a/EfpAnalyzer/EfpAnalyzer/Components/Pages/Weather.razor
+++ /dev/null
@@ -1,64 +0,0 @@
-@page "/weather"
-@attribute [StreamRendering]
-
-Weather
-
-Weather
-
-This component demonstrates showing data.
-
-@if (forecasts == null)
-{
- Loading...
-}
-else
-{
-
-
-
- Date
- Temp. (C)
- Temp. (F)
- Summary
-
-
-
- @foreach (var forecast in forecasts)
- {
-
- @forecast.Date.ToShortDateString()
- @forecast.TemperatureC
- @forecast.TemperatureF
- @forecast.Summary
-
- }
-
-
-}
-
-@code {
- private WeatherForecast[]? forecasts;
-
- protected override async Task OnInitializedAsync()
- {
- // Simulate asynchronous loading to demonstrate streaming rendering
- await Task.Delay(500);
-
- var startDate = DateOnly.FromDateTime(DateTime.Now);
- var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
- forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
- {
- Date = startDate.AddDays(index),
- TemperatureC = Random.Shared.Next(-20, 55),
- Summary = summaries[Random.Shared.Next(summaries.Length)]
- }).ToArray();
- }
-
- private class WeatherForecast
- {
- public DateOnly Date { get; set; }
- public int TemperatureC { get; set; }
- public string? Summary { get; set; }
- public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
- }
-}
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/_Imports.razor b/EfpAnalyzer/EfpAnalyzer/Components/_Imports.razor
index 44d8135..82011cb 100644
--- a/EfpAnalyzer/EfpAnalyzer/Components/_Imports.razor
+++ b/EfpAnalyzer/EfpAnalyzer/Components/_Imports.razor
@@ -1,4 +1,4 @@
-@using System.Net.Http
+@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@@ -7,5 +7,8 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using EfpAnalyzer
+@using EfpAnalyzer.Models
+@using EfpAnalyzer.Services
@using EfpAnalyzer.Components
@using EfpAnalyzer.Components.Layout
+@using MudBlazor
diff --git a/EfpAnalyzer/EfpAnalyzer/Program.cs b/EfpAnalyzer/EfpAnalyzer/Program.cs
index 3f9d675..d1d24f1 100644
--- a/EfpAnalyzer/EfpAnalyzer/Program.cs
+++ b/EfpAnalyzer/EfpAnalyzer/Program.cs
@@ -1,25 +1,38 @@
using EfpAnalyzer.Components;
+using EfpAnalyzer.Models;
+using EfpAnalyzer.Services;
+using MudBlazor.Services;
var builder = WebApplication.CreateBuilder(args);
-// Add services to the container.
+// Add configuration from environment variables
+builder.Configuration.AddEnvironmentVariables();
+
+// Add services to the container
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
+builder.Services.AddMudServices();
+builder.Services.AddHttpClient();
+
+// Register application services
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
var app = builder.Build();
-// Configure the HTTP request pipeline.
+// Configure the HTTP request pipeline
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
- // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
+
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
-
app.UseAntiforgery();
-
app.MapStaticAssets();
app.MapRazorComponents()
.AddInteractiveServerRenderMode();
diff --git a/EfpAnalyzer/EfpAnalyzer/wwwroot/app.css b/EfpAnalyzer/EfpAnalyzer/wwwroot/app.css
index 73a69d6..43c1e11 100644
--- a/EfpAnalyzer/EfpAnalyzer/wwwroot/app.css
+++ b/EfpAnalyzer/EfpAnalyzer/wwwroot/app.css
@@ -1,60 +1,15 @@
-html, body {
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
-}
-
-a, .btn-link {
- color: #006bb7;
-}
-
-.btn-primary {
- color: #fff;
- background-color: #1b6ec2;
- border-color: #1861ac;
-}
-
-.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
- box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
-}
-
-.content {
- padding-top: 1.1rem;
-}
+/* MudBlazor handles most styling. Minimal overrides only. */
h1:focus {
outline: none;
}
-.valid.modified:not([type=checkbox]) {
- outline: 1px solid #26b050;
-}
-
-.invalid {
- outline: 1px solid #e50000;
-}
-
-.validation-message {
- color: #e50000;
-}
-
.blazor-error-boundary {
- background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
- padding: 1rem 1rem 1rem 3.7rem;
+ background: #b32121;
+ padding: 1rem;
color: white;
}
- .blazor-error-boundary::after {
- content: "An error has occurred."
- }
-
-.darker-border-checkbox.form-check-input {
- border-color: #929292;
-}
-
-.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
- color: var(--bs-secondary-color);
- text-align: end;
-}
-
-.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
- text-align: start;
+.blazor-error-boundary::after {
+ content: "An error has occurred.";
}
\ No newline at end of file
From 4d7847d47ae6e784bd54ce9290367ef2b251ad4d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 19:30:12 +0000
Subject: [PATCH 05/17] Add xUnit tests for EfpAnalyzer Models (45 tests across
5 files)
Co-authored-by: amgdy <1763289+amgdy@users.noreply.github.com>
---
.dockerignore | 36 +---
Dockerfile | 69 +++----
EfpAnalyzer/EfpAnalyzer/Program.cs | 1 +
.../Models/ComparisonModelsTests.cs | 38 ++++
.../Models/DurationFormatterTests.cs | 33 ++++
.../Models/EvaluationModelsTests.cs | 49 +++++
.../Models/ProcessingQueueTests.cs | 187 ++++++++++++++++++
.../Models/ScoringModelsTests.cs | 114 +++++++++++
.../tests/EfpAnalyzer.Tests/UnitTest1.cs | 10 -
azure.yaml | 4 +-
10 files changed, 459 insertions(+), 82 deletions(-)
create mode 100644 EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ComparisonModelsTests.cs
create mode 100644 EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/DurationFormatterTests.cs
create mode 100644 EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/EvaluationModelsTests.cs
create mode 100644 EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ProcessingQueueTests.cs
create mode 100644 EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ScoringModelsTests.cs
delete mode 100644 EfpAnalyzer/tests/EfpAnalyzer.Tests/UnitTest1.cs
diff --git a/.dockerignore b/.dockerignore
index 1c1f7e9..5ddfd99 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,25 +1,22 @@
# =============================================================================
-# RFP Analyzer - Docker Ignore
+# EFP Analyzer - Docker Ignore
# =============================================================================
-# Files and directories to exclude from Docker build context
# Git
.git
.gitignore
-# Python
+# .NET build artifacts
+**/bin
+**/obj
+**/out
+**/.vs
+
+# Python (legacy)
__pycache__
*.py[cod]
-*$py.class
-*.so
-.Python
.venv
venv
-ENV
-env
-.eggs
-*.egg-info
-*.egg
# IDE and editors
.vscode
@@ -39,9 +36,7 @@ Thumbs.db
# Test files
test.http
-tests/
-*.test.py
-*_test.py
+**/tests/
# Documentation (not needed in container)
docs/
@@ -49,19 +44,6 @@ docs/
!app/scoring_guide.md
!README.md
-# Build artifacts
-dist/
-build/
-*.whl
-
# Logs
*.log
logs/
-
-# uv cache (we use uv.lock but not cache)
-.uv_cache
-
-# Misc
-.coverage
-.pytest_cache
-htmlcov/
diff --git a/Dockerfile b/Dockerfile
index f16e3e4..9631499 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,73 +1,56 @@
# =============================================================================
-# RFP Analyzer - Dockerfile
+# EFP Analyzer - Dockerfile (.NET 10 Blazor)
# =============================================================================
# Multi-stage build for optimized image size
-# Includes WeasyPrint dependencies for PDF export
# -----------------------------------------------------------------------------
-# Stage 1: Build stage with uv for fast dependency installation
+# Stage 1: Build stage
# -----------------------------------------------------------------------------
-FROM python:3.13-slim AS builder
+FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
-# Install uv for fast package management
-COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
+WORKDIR /src
-# Set working directory
-WORKDIR /app
+# Copy solution and project files first for better caching
+COPY EfpAnalyzer/EfpAnalyzer.slnx ./EfpAnalyzer/
+COPY EfpAnalyzer/global.json ./EfpAnalyzer/
+COPY EfpAnalyzer/EfpAnalyzer/EfpAnalyzer.csproj ./EfpAnalyzer/EfpAnalyzer/
+
+# Restore dependencies
+RUN dotnet restore EfpAnalyzer/EfpAnalyzer/EfpAnalyzer.csproj
-# Copy dependency files first for better caching (now from app folder)
-COPY app/pyproject.toml app/uv.lock ./
+# Copy all source code
+COPY EfpAnalyzer/ ./EfpAnalyzer/
-# Install dependencies (including PDF export extras)
-RUN uv sync --frozen --no-dev --all-extras
+# Build and publish
+RUN dotnet publish EfpAnalyzer/EfpAnalyzer/EfpAnalyzer.csproj -c Release -o /app/publish --no-restore
# -----------------------------------------------------------------------------
# Stage 2: Runtime stage
# -----------------------------------------------------------------------------
-FROM python:3.13-slim AS runtime
-
-# Install system dependencies for WeasyPrint (PDF export)
-RUN apt-get update && apt-get install -y --no-install-recommends \
- libpango-1.0-0 \
- libpangocairo-1.0-0 \
- libgdk-pixbuf-2.0-0 \
- libffi-dev \
- shared-mime-info \
- && rm -rf /var/lib/apt/lists/* \
- && apt-get clean
+FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS runtime
# Create non-root user for security
RUN useradd --create-home --shell /bin/bash appuser
-# Set working directory
WORKDIR /app
-# Copy virtual environment from builder
-COPY --from=builder /app/.venv /app/.venv
-
-# Copy application code
-COPY app/ ./
+# Copy published output from build stage
+COPY --from=build /app/publish .
# Set environment variables
-ENV PATH="/app/.venv/bin:$PATH"
-ENV PYTHONPATH="/app"
-ENV PYTHONUNBUFFERED=1
-
-# Streamlit configuration
-ENV STREAMLIT_SERVER_PORT=8501
-ENV STREAMLIT_SERVER_ADDRESS=0.0.0.0
-ENV STREAMLIT_SERVER_HEADLESS=true
-ENV STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
+ENV ASPNETCORE_URLS=http://+:8501
+ENV ASPNETCORE_ENVIRONMENT=Production
+ENV DOTNET_RUNNING_IN_CONTAINER=true
# Switch to non-root user
USER appuser
-# Expose Streamlit port
+# Expose port (matching original Streamlit port for IaC compatibility)
EXPOSE 8501
# Health check
-HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
- CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8501/_stcore/health')" || exit 1
+HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
+ CMD curl -f http://localhost:8501/health || exit 1
-# Run Streamlit
-CMD ["streamlit", "run", "main.py", "--server.port=8501", "--server.address=0.0.0.0"]
+# Run the application
+ENTRYPOINT ["dotnet", "EfpAnalyzer.dll"]
diff --git a/EfpAnalyzer/EfpAnalyzer/Program.cs b/EfpAnalyzer/EfpAnalyzer/Program.cs
index d1d24f1..5e3c6cb 100644
--- a/EfpAnalyzer/EfpAnalyzer/Program.cs
+++ b/EfpAnalyzer/EfpAnalyzer/Program.cs
@@ -34,6 +34,7 @@
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
+app.MapGet("/health", () => Results.Ok("Healthy"));
app.MapRazorComponents()
.AddInteractiveServerRenderMode();
diff --git a/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ComparisonModelsTests.cs b/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ComparisonModelsTests.cs
new file mode 100644
index 0000000..dfb95ca
--- /dev/null
+++ b/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ComparisonModelsTests.cs
@@ -0,0 +1,38 @@
+using EfpAnalyzer.Models;
+
+namespace EfpAnalyzer.Tests.Models;
+
+public class ComparisonModelsTests
+{
+ [Fact]
+ public void ComparisonResult_DefaultValues()
+ {
+ var result = new ComparisonResult();
+ Assert.Equal("", result.RfpTitle);
+ Assert.Equal(0, result.TotalVendors);
+ Assert.Empty(result.VendorRankings);
+ Assert.Empty(result.CriterionComparisons);
+ Assert.Empty(result.ComparisonInsights);
+ }
+
+ [Fact]
+ public void VendorRanking_DefaultValues()
+ {
+ var ranking = new VendorRanking();
+ Assert.Equal(0, ranking.Rank);
+ Assert.Equal("", ranking.VendorName);
+ Assert.Equal(0, ranking.TotalScore);
+ Assert.Empty(ranking.KeyStrengths);
+ Assert.Empty(ranking.KeyConcerns);
+ }
+
+ [Fact]
+ public void CriterionComparison_DefaultValues()
+ {
+ var comp = new CriterionComparison();
+ Assert.Equal("", comp.CriterionId);
+ Assert.Equal("", comp.BestVendor);
+ Assert.Equal("", comp.WorstVendor);
+ Assert.Equal(0, comp.Weight);
+ }
+}
diff --git a/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/DurationFormatterTests.cs b/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/DurationFormatterTests.cs
new file mode 100644
index 0000000..56fa39b
--- /dev/null
+++ b/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/DurationFormatterTests.cs
@@ -0,0 +1,33 @@
+using EfpAnalyzer.Models;
+
+namespace EfpAnalyzer.Tests.Models;
+
+public class DurationFormatterTests
+{
+ [Theory]
+ [InlineData(0.5, "500ms")]
+ [InlineData(0.001, "1ms")]
+ [InlineData(0.999, "999ms")]
+ public void Format_SubSecond_ReturnsMilliseconds(double seconds, string expected)
+ {
+ Assert.Equal(expected, DurationFormatter.Format(seconds));
+ }
+
+ [Theory]
+ [InlineData(1.0, "1.0s")]
+ [InlineData(5.5, "5.5s")]
+ [InlineData(59.9, "59.9s")]
+ public void Format_Seconds_ReturnsSeconds(double seconds, string expected)
+ {
+ Assert.Equal(expected, DurationFormatter.Format(seconds));
+ }
+
+ [Theory]
+ [InlineData(60, "1m 0.0s")]
+ [InlineData(90, "1m 30.0s")]
+ [InlineData(125.5, "2m 5.5s")]
+ public void Format_Minutes_ReturnsMinutesAndSeconds(double seconds, string expected)
+ {
+ Assert.Equal(expected, DurationFormatter.Format(seconds));
+ }
+}
diff --git a/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/EvaluationModelsTests.cs b/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/EvaluationModelsTests.cs
new file mode 100644
index 0000000..314f211
--- /dev/null
+++ b/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/EvaluationModelsTests.cs
@@ -0,0 +1,49 @@
+using EfpAnalyzer.Models;
+
+namespace EfpAnalyzer.Tests.Models;
+
+public class EvaluationModelsTests
+{
+ [Fact]
+ public void EvaluationResult_DefaultValues()
+ {
+ var result = new EvaluationResult();
+ Assert.Equal("", result.RfpTitle);
+ Assert.Equal("", result.SupplierName);
+ Assert.Equal(0, result.TotalScore);
+ Assert.Empty(result.CriterionScores);
+ Assert.Null(result.Metadata);
+ }
+
+ [Fact]
+ public void UploadedDocument_DefaultValues()
+ {
+ var doc = new UploadedDocument();
+ Assert.Equal("", doc.FileName);
+ Assert.Empty(doc.Content);
+ Assert.False(doc.IsExtracted);
+ Assert.Null(doc.ExtractedContent);
+ }
+
+ [Fact]
+ public void AppState_DefaultValues()
+ {
+ var state = new AppState();
+ Assert.Null(state.RfpDocument);
+ Assert.Empty(state.ProposalDocuments);
+ Assert.Equal(ExtractionService.ContentUnderstanding, state.SelectedService);
+ Assert.Equal("high", state.ReasoningEffort);
+ Assert.Empty(state.EvaluationResults);
+ Assert.Null(state.ComparisonResult);
+ Assert.Equal(0, state.CurrentStep);
+ }
+
+ [Fact]
+ public void EvaluationMetadata_DefaultValues()
+ {
+ var meta = new EvaluationMetadata();
+ Assert.Equal("2.0", meta.Version);
+ Assert.Equal("multi-agent", meta.EvaluationType);
+ Assert.Equal(0, meta.TotalDurationSeconds);
+ }
+}
diff --git a/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ProcessingQueueTests.cs b/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ProcessingQueueTests.cs
new file mode 100644
index 0000000..35c58a2
--- /dev/null
+++ b/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ProcessingQueueTests.cs
@@ -0,0 +1,187 @@
+using EfpAnalyzer.Models;
+
+namespace EfpAnalyzer.Tests.Models;
+
+public class ProcessingQueueTests
+{
+ [Fact]
+ public void QueueItem_Start_SetsProcessingStatus()
+ {
+ var item = new QueueItem { Id = "1", Name = "Test", ItemType = "test" };
+ item.Start();
+ Assert.Equal(QueueItemStatus.Processing, item.Status);
+ Assert.NotNull(item.StartTime);
+ }
+
+ [Fact]
+ public void QueueItem_Complete_SetsCompletedStatusAndDuration()
+ {
+ var item = new QueueItem { Id = "1", Name = "Test", ItemType = "test" };
+ item.Start();
+ Thread.Sleep(10);
+ item.Complete("result");
+ Assert.Equal(QueueItemStatus.Completed, item.Status);
+ Assert.NotNull(item.EndTime);
+ Assert.NotNull(item.Duration);
+ Assert.True(item.Duration > 0);
+ Assert.Equal("result", item.Result);
+ }
+
+ [Fact]
+ public void QueueItem_Fail_SetsFailedStatusAndError()
+ {
+ var item = new QueueItem { Id = "1", Name = "Test", ItemType = "test" };
+ item.Start();
+ item.Fail("Something went wrong");
+ Assert.Equal(QueueItemStatus.Failed, item.Status);
+ Assert.Equal("Something went wrong", item.ErrorMessage);
+ }
+
+ [Fact]
+ public void QueueItem_GetStatusIcon_ReturnsCorrectIcons()
+ {
+ var pending = new QueueItem { Id = "1", Name = "T", ItemType = "t" };
+ Assert.Equal("⏳", pending.GetStatusIcon());
+
+ pending.Start();
+ Assert.Equal("🔄", pending.GetStatusIcon());
+
+ pending.Complete();
+ Assert.Equal("✅", pending.GetStatusIcon());
+
+ var failed = new QueueItem { Id = "2", Name = "T", ItemType = "t" };
+ failed.Start();
+ failed.Fail("err");
+ Assert.Equal("❌", failed.GetStatusIcon());
+ }
+
+ [Fact]
+ public void QueueItem_GetElapsedTime_ReturnsZeroWhenNotStarted()
+ {
+ var item = new QueueItem { Id = "1", Name = "T", ItemType = "t" };
+ Assert.Equal(0.0, item.GetElapsedTime());
+ }
+
+ [Fact]
+ public void QueueItem_GetElapsedTime_ReturnsDurationWhenCompleted()
+ {
+ var item = new QueueItem { Id = "1", Name = "T", ItemType = "t" };
+ item.Start();
+ Thread.Sleep(50);
+ item.Complete();
+ Assert.True(item.GetElapsedTime() > 0);
+ }
+
+ [Fact]
+ public void ProcessingQueue_AddItem_AddsToList()
+ {
+ var queue = new ProcessingQueue { Name = "Test" };
+ var item = queue.AddItem("1", "Test Item", "test");
+ Assert.Single(queue.Items);
+ Assert.Equal("1", item.Id);
+ Assert.Equal("Test Item", item.Name);
+ Assert.Equal("test", item.ItemType);
+ }
+
+ [Fact]
+ public void ProcessingQueue_GetItem_FindsById()
+ {
+ var queue = new ProcessingQueue { Name = "Test" };
+ queue.AddItem("1", "First", "test");
+ queue.AddItem("2", "Second", "test");
+
+ var found = queue.GetItem("2");
+ Assert.NotNull(found);
+ Assert.Equal("Second", found.Name);
+
+ var notFound = queue.GetItem("99");
+ Assert.Null(notFound);
+ }
+
+ [Fact]
+ public void ProcessingQueue_GetProgress_ReturnsCorrectCounts()
+ {
+ var queue = new ProcessingQueue { Name = "Test" };
+ var item1 = queue.AddItem("1", "A", "test");
+ var item2 = queue.AddItem("2", "B", "test");
+ var item3 = queue.AddItem("3", "C", "test");
+ var item4 = queue.AddItem("4", "D", "test");
+
+ item1.Start();
+ item1.Complete();
+ item2.Start();
+ item3.Start();
+ item3.Fail("err");
+
+ var progress = queue.GetProgress();
+ Assert.Equal(4, progress.Total);
+ Assert.Equal(1, progress.Completed);
+ Assert.Equal(1, progress.Failed);
+ Assert.Equal(1, progress.Processing);
+ Assert.Equal(1, progress.Pending);
+ Assert.Equal(50, progress.Percentage); // (1 completed + 1 failed) / 4 = 50%
+ }
+
+ [Fact]
+ public void ProcessingQueue_IsComplete_TrueWhenAllDone()
+ {
+ var queue = new ProcessingQueue { Name = "Test" };
+ var item1 = queue.AddItem("1", "A", "test");
+ var item2 = queue.AddItem("2", "B", "test");
+
+ Assert.False(queue.IsComplete);
+
+ item1.Start();
+ item1.Complete();
+ Assert.False(queue.IsComplete);
+
+ item2.Start();
+ item2.Fail("err");
+ Assert.True(queue.IsComplete);
+ }
+
+ [Fact]
+ public void ProcessingQueue_Clear_RemovesAllItems()
+ {
+ var queue = new ProcessingQueue { Name = "Test" };
+ queue.Start();
+ queue.AddItem("1", "A", "test");
+ queue.AddItem("2", "B", "test");
+
+ queue.Clear();
+ Assert.Empty(queue.Items);
+ Assert.Null(queue.StartTime);
+ Assert.Null(queue.EndTime);
+ }
+
+ [Fact]
+ public void ProcessingQueue_GetTotalDuration_ReturnsZeroWhenNotStarted()
+ {
+ var queue = new ProcessingQueue { Name = "Test" };
+ Assert.Equal(0.0, queue.GetTotalDuration());
+ }
+
+ [Fact]
+ public void ProcessingQueue_GetAverageItemDuration_ReturnsZeroWithNoCompleted()
+ {
+ var queue = new ProcessingQueue { Name = "Test" };
+ queue.AddItem("1", "A", "test");
+ Assert.Equal(0.0, queue.GetAverageItemDuration());
+ }
+
+ [Fact]
+ public void ProcessingQueue_GetPendingCompletedFailed_FiltersCorrectly()
+ {
+ var queue = new ProcessingQueue { Name = "Test" };
+ var a = queue.AddItem("1", "A", "test");
+ var b = queue.AddItem("2", "B", "test");
+ var c = queue.AddItem("3", "C", "test");
+
+ a.Start(); a.Complete();
+ b.Start(); b.Fail("err");
+
+ Assert.Single(queue.GetCompletedItems());
+ Assert.Single(queue.GetFailedItems());
+ Assert.Single(queue.GetPendingItems());
+ }
+}
diff --git a/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ScoringModelsTests.cs b/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ScoringModelsTests.cs
new file mode 100644
index 0000000..6a9582e
--- /dev/null
+++ b/EfpAnalyzer/tests/EfpAnalyzer.Tests/Models/ScoringModelsTests.cs
@@ -0,0 +1,114 @@
+using EfpAnalyzer.Models;
+
+namespace EfpAnalyzer.Tests.Models;
+
+public class ScoringModelsTests
+{
+ [Fact]
+ public void ExtractedCriteria_DefaultValues()
+ {
+ var criteria = new ExtractedCriteria();
+ Assert.Equal("", criteria.RfpTitle);
+ Assert.Equal(100.0, criteria.TotalWeight);
+ Assert.Empty(criteria.Criteria);
+ }
+
+ [Fact]
+ public void CriterionScore_WeightedScoreCalculation()
+ {
+ // This tests the business logic formula: weighted_score = (raw_score * weight) / 100
+ var score = new CriterionScore
+ {
+ CriterionId = "C-1",
+ CriterionName = "Technical",
+ Weight = 30,
+ RawScore = 85,
+ WeightedScore = 85 * 30.0 / 100 // = 25.5
+ };
+
+ Assert.Equal(25.5, score.WeightedScore);
+ }
+
+ [Fact]
+ public void ProposalEvaluation_DefaultValues()
+ {
+ var eval = new ProposalEvaluation();
+ Assert.Equal("", eval.RfpTitle);
+ Assert.Equal(0, eval.TotalScore);
+ Assert.Equal("", eval.Grade);
+ Assert.Empty(eval.CriterionScores);
+ Assert.Empty(eval.OverallStrengths);
+ Assert.Empty(eval.OverallWeaknesses);
+ }
+
+ [Theory]
+ [InlineData(95, "A")]
+ [InlineData(90, "A")]
+ [InlineData(85, "B")]
+ [InlineData(80, "B")]
+ [InlineData(75, "C")]
+ [InlineData(70, "C")]
+ [InlineData(65, "D")]
+ [InlineData(60, "D")]
+ [InlineData(55, "F")]
+ [InlineData(0, "F")]
+ public void GradeAssignment_MatchesPythonLogic(double score, string expectedGrade)
+ {
+ // This replicates the grade assignment from scoring_agent_v2.py
+ string grade = score switch
+ {
+ >= 90 => "A",
+ >= 80 => "B",
+ >= 70 => "C",
+ >= 60 => "D",
+ _ => "F"
+ };
+ Assert.Equal(expectedGrade, grade);
+ }
+
+ [Fact]
+ public void WeightNormalization_SumsTo100()
+ {
+ // Test the weight normalization logic from CriteriaExtractionAgent._parse_response()
+ var criteria = new List
+ {
+ new() { CriterionId = "C-1", Weight = 40 },
+ new() { CriterionId = "C-2", Weight = 30 },
+ new() { CriterionId = "C-3", Weight = 50 } // Total: 120, not 100
+ };
+
+ var totalWeight = criteria.Sum(c => c.Weight);
+ if (Math.Abs(totalWeight - 100) > 0.1)
+ {
+ foreach (var c in criteria)
+ {
+ c.Weight = c.Weight / totalWeight * 100;
+ }
+ }
+
+ var newTotal = criteria.Sum(c => c.Weight);
+ Assert.Equal(100.0, newTotal, 1); // within 0.1
+
+ // Verify proportions maintained
+ Assert.Equal(100.0 * 40 / 120, criteria[0].Weight, 1);
+ Assert.Equal(100.0 * 30 / 120, criteria[1].Weight, 1);
+ Assert.Equal(100.0 * 50 / 120, criteria[2].Weight, 1);
+ }
+
+ [Fact]
+ public void TotalScoreCalculation_SumOfWeightedScores()
+ {
+ // Test total_score = SUM(weighted_scores)
+ var scores = new List
+ {
+ new() { RawScore = 80, Weight = 30, WeightedScore = 24.0 },
+ new() { RawScore = 90, Weight = 25, WeightedScore = 22.5 },
+ new() { RawScore = 70, Weight = 20, WeightedScore = 14.0 },
+ new() { RawScore = 85, Weight = 15, WeightedScore = 12.75 },
+ new() { RawScore = 60, Weight = 10, WeightedScore = 6.0 },
+ };
+
+ var totalScore = Math.Round(scores.Sum(s => s.WeightedScore), 2);
+ Assert.Equal(79.25, totalScore);
+ }
+}
diff --git a/EfpAnalyzer/tests/EfpAnalyzer.Tests/UnitTest1.cs b/EfpAnalyzer/tests/EfpAnalyzer.Tests/UnitTest1.cs
deleted file mode 100644
index c0b6c53..0000000
--- a/EfpAnalyzer/tests/EfpAnalyzer.Tests/UnitTest1.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace EfpAnalyzer.Tests;
-
-public class UnitTest1
-{
- [Fact]
- public void Test1()
- {
-
- }
-}
diff --git a/azure.yaml b/azure.yaml
index db4ca5b..b5c2372 100644
--- a/azure.yaml
+++ b/azure.yaml
@@ -2,9 +2,9 @@
name: rfp-analyzer
services:
rfp-analyzer:
- project: app
+ project: EfpAnalyzer/EfpAnalyzer
host: containerapp
- language: python
+ language: dotnet
docker:
path: Dockerfile
remoteBuild: true
From 83f52f54697bd9b8c192d75fc82d0258354b8da1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 19:33:49 +0000
Subject: [PATCH 06/17] Update README.md to reflect .NET 10 Blazor rewrite
Co-authored-by: amgdy <1763289+amgdy@users.noreply.github.com>
---
README.md | 201 ++++++++++++++++++++++--------------------------------
1 file changed, 82 insertions(+), 119 deletions(-)
diff --git a/README.md b/README.md
index 4188896..f78d2ff 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,21 @@
# RFP Analyzer
[](https://opensource.org/licenses/MIT)
-[](https://www.python.org/downloads/)
+[](https://dotnet.microsoft.com/)
[](https://azure.microsoft.com)
-An AI-powered application for analyzing Request for Proposals (RFPs) and scoring vendor proposals using Azure AI services and a multi-agent architecture.
+An AI-powered application for analyzing Request for Proposals (RFPs) and scoring vendor proposals using Azure AI services. Built with .NET 10 Blazor and MudBlazor.
## 🎯 Overview
-RFP Analyzer automates the complex process of evaluating vendor proposals against RFP requirements. It leverages Azure AI services to extract document content, analyze evaluation criteria, and score proposals using a sophisticated multi-agent system.
+RFP Analyzer automates the complex process of evaluating vendor proposals against RFP requirements. It leverages Azure AI services to extract document content, analyze evaluation criteria, and score proposals using specialized services.
### Key Capabilities
- **Automated Document Processing**: Extract content from PDFs, Word documents, and images using Azure AI
- **Intelligent Criteria Extraction**: Automatically identify evaluation criteria and weights from RFP documents
- **Multi-Vendor Comparison**: Evaluate and rank multiple vendor proposals simultaneously
-- **Comprehensive Reporting**: Generate detailed reports in Word, CSV, and JSON formats
+- **Comprehensive Reporting**: Generate detailed reports in Excel, CSV, and JSON formats
## ✨ Features
@@ -41,22 +41,19 @@ RFP Analyzer automates the complex process of evaluating vendor proposals agains
| **Azure Content Understanding** | Complex documents, mixed content | Multi-modal analysis, layout understanding |
| **Azure Document Intelligence** | Structured documents, forms | High accuracy OCR, pre-built models |
-### Multi-Agent Architecture
+### Scoring & Comparison Services
-The evaluation system uses specialized AI agents:
-
-| Agent | Responsibility |
-|-------|----------------|
-| **Criteria Extraction Agent** | Analyzes RFP to identify scoring criteria, weights, and evaluation guidance |
-| **Proposal Scoring Agent** | Evaluates each vendor proposal against extracted criteria |
-| **Comparison Agent** | Compares vendors, generates rankings, and provides recommendations |
+| Service | Responsibility |
+|---------|----------------|
+| **ScoringService** | Analyzes RFP to extract criteria and scores each vendor proposal |
+| **ComparisonService** | Compares vendors, generates rankings, and provides recommendations |
### Export Options
- 📊 **CSV Reports**: Comparison matrices with all metrics
-- 📄 **Word Documents**: Detailed evaluation reports per vendor
+- 📗 **Excel Documents**: Detailed evaluation reports per vendor
- 📋 **JSON Data**: Structured data for further processing
-- 📈 **Interactive Charts**: Visual score comparisons (requires Plotly)
+- 📈 **Interactive Charts**: Visual score comparisons via Plotly.Blazor
## 🏗️ Architecture
@@ -67,10 +64,10 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed diagrams and compo
```mermaid
flowchart TB
subgraph App["🖥️ RFP Analyzer Application"]
- UI["Streamlit UI"]
- DP["Document Processor"]
- MAS["Multi-Agent Scoring System"]
- UI --> DP --> MAS
+ UI["Blazor / MudBlazor UI"]
+ DP["Document Processor Service"]
+ SS["Scoring & Comparison Services"]
+ UI --> DP --> SS
end
subgraph Azure["☁️ Azure AI Services"]
@@ -90,7 +87,7 @@ flowchart TB
| Resource | Purpose |
|----------|---------|
| **Azure AI Foundry Account** | Hosts AI services (OpenAI, Content Understanding, Document Intelligence) |
-| **Azure Container Apps** | Runs the Streamlit application |
+| **Azure Container Apps** | Runs the Blazor application |
| **Azure Container Registry** | Stores application container images |
| **Log Analytics Workspace** | Centralized logging and monitoring |
| **Application Insights** | Application performance monitoring |
@@ -100,8 +97,7 @@ flowchart TB
### Prerequisites
-- **Python 3.13+** - [Download](https://www.python.org/downloads/)
-- **UV Package Manager** - [Install UV](https://docs.astral.sh/uv/getting-started/installation/)
+- **.NET 10 SDK** - [Download](https://dotnet.microsoft.com/download/dotnet/10.0)
- **Azure CLI** - [Install Azure CLI](https://docs.microsoft.com/cli/azure/install-azure-cli)
- **Azure Developer CLI (azd)** - [Install azd](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd)
- **Docker** (optional) - For containerized deployment
@@ -121,29 +117,24 @@ Your Azure subscription needs:
cd rfp-analyzer
```
-2. **Install dependencies**
- ```bash
- cd app
- uv sync
- ```
-
-3. **Configure environment**
+2. **Authenticate with Azure**
```bash
- cp .env.example .env
- # Edit .env with your Azure credentials
+ az login
```
-4. **Authenticate with Azure**
+3. **Configure app settings**
```bash
- az login
+ # Edit EfpAnalyzer/EfpAnalyzer/appsettings.Development.json with your Azure endpoints
```
-5. **Run the application**
+4. **Build and run**
```bash
- uv run streamlit run main.py
+ cd EfpAnalyzer
+ dotnet build
+ dotnet run --project EfpAnalyzer
```
-6. **Open your browser** at `http://localhost:8501`
+5. **Open your browser** at `https://localhost:5001` (or the URL shown in the console)
## ☁️ Azure Deployment
@@ -204,46 +195,29 @@ The following environment variables are configured automatically during Azure de
### Manual Configuration (Local Development)
-For local development, create a `.env` file in the `app` directory:
-
-```env
-# Required
-AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
-AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o-mini
-
-# Choose one extraction service
-AZURE_CONTENT_UNDERSTANDING_ENDPOINT=https://your-ai-foundry.services.ai.azure.com/
-# OR
-AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT=https://your-doc-intel.cognitiveservices.azure.com/
-
-# Optional: Enable OpenTelemetry logging
-OTEL_LOGGING_ENABLED=false
+For local development, edit `EfpAnalyzer/EfpAnalyzer/appsettings.Development.json`:
+
+```json
+{
+ "AzureOpenAI": {
+ "Endpoint": "https://your-resource.openai.azure.com/",
+ "DeploymentName": "gpt-4o-mini"
+ },
+ "AzureContentUnderstanding": {
+ "Endpoint": "https://your-ai-foundry.services.ai.azure.com/"
+ },
+ "AzureDocumentIntelligence": {
+ "Endpoint": "https://your-doc-intel.cognitiveservices.azure.com/"
+ }
+}
```
## 🐳 Docker Deployment
-### Using Docker Compose (Recommended)
-
-```bash
-cd app
-
-# Configure environment
-cp .env.example .env
-# Edit .env with your Azure credentials
-
-# Build and run
-docker compose up --build
-
-# Run in background
-docker compose up -d
-```
-
### Using Docker Directly
```bash
-cd app
-
-# Build the image
+# Build the image from the repository root
docker build -t rfp-analyzer .
# Run the container
@@ -266,25 +240,29 @@ rfp-analyzer/
├── README.md # This file
├── LICENSE # MIT License
├── azure.yaml # Azure Developer CLI configuration
-├── Dockerfile # Root Dockerfile
+├── Dockerfile # Multi-stage Docker build
├── docs/
│ └── ARCHITECTURE.md # Detailed architecture documentation
-├── app/
-│ ├── main.py # Streamlit application entry point
-│ ├── pyproject.toml # Python dependencies (UV)
-│ ├── requirements.txt # Python dependencies (pip)
-│ ├── Dockerfile # Application Dockerfile
-│ ├── docker-compose.yml # Docker Compose configuration
-│ ├── .env.example # Environment template
-│ ├── scoring_guide.md # Default evaluation criteria
-│ └── services/
-│ ├── document_processor.py # Document extraction orchestrator
-│ ├── content_understanding_client.py # Azure Content Understanding
-│ ├── document_intelligence_client.py # Azure Document Intelligence
-│ ├── scoring_agent_v2.py # Multi-agent scoring system
-│ ├── comparison_agent.py # Vendor comparison agent
-│ ├── processing_queue.py # Async processing queue
-│ └── logging_config.py # Centralized logging configuration
+├── EfpAnalyzer/
+│ ├── EfpAnalyzer.slnx # Solution file
+│ ├── global.json # .NET SDK version pinning
+│ ├── EfpAnalyzer/ # Main Blazor Web App project
+│ │ ├── EfpAnalyzer.csproj # Project file (net10.0)
+│ │ ├── Program.cs # Application entry point
+│ │ ├── Components/
+│ │ │ ├── App.razor # Root component
+│ │ │ ├── Routes.razor # Routing configuration
+│ │ │ ├── Layout/ # MainLayout, NavMenu
+│ │ │ └── Pages/ # Upload, Extract, Evaluate pages
+│ │ ├── Services/
+│ │ │ ├── DocumentProcessorService.cs # Document extraction orchestrator
+│ │ │ ├── ScoringService.cs # AI-powered proposal scoring
+│ │ │ └── ComparisonService.cs # Vendor comparison & ranking
+│ │ ├── Models/ # Data models (Scoring, Evaluation, etc.)
+│ │ ├── Properties/ # Launch settings
+│ │ └── wwwroot/ # Static assets
+│ └── tests/
+│ └── EfpAnalyzer.Tests/ # Unit tests
└── infra/
├── main.bicep # Main infrastructure template
├── main.parameters.json # Deployment parameters
@@ -299,13 +277,9 @@ rfp-analyzer/
## 🔧 Configuration
-### Scoring Guide
-
-Edit `app/scoring_guide.md` to customize the default evaluation criteria and weights. The scoring agent will use this as a reference when extracting criteria from RFPs that don't explicitly define evaluation metrics.
-
### Document Processing
-Choose between extraction services in the application sidebar:
+Choose between extraction services in the application:
- **Azure Content Understanding**: Best for complex documents with mixed content
- **Azure Document Intelligence**: Best for structured documents and forms
@@ -347,33 +321,30 @@ The application supports multiple Azure OpenAI models:
Download results in your preferred format:
- **CSV**: For spreadsheet analysis
-- **Word**: For formal reporting
+- **Excel**: For formal reporting
- **JSON**: For integration with other systems
## 🧪 Development
-### Running Tests
+### Building
```bash
-cd app
-uv run pytest
+cd EfpAnalyzer
+dotnet build
```
-### Code Quality
+### Running Tests
```bash
-# Format code
-uv run ruff format .
-
-# Lint code
-uv run ruff check .
+cd EfpAnalyzer
+dotnet test
```
### Local Development with Hot Reload
```bash
-cd app
-uv run streamlit run main.py --server.runOnSave true
+cd EfpAnalyzer
+dotnet watch --project EfpAnalyzer
```
## 📦 Dependencies
@@ -382,20 +353,12 @@ uv run streamlit run main.py --server.runOnSave true
| Package | Purpose |
|---------|---------|
-| `streamlit` | Web application framework |
-| `agent-framework` | Microsoft Agent Framework for multi-agent orchestration |
-| `azure-identity` | Azure authentication |
-| `azure-ai-documentintelligence` | Document Intelligence SDK |
-| `pydantic` | Data validation and models |
-| `python-docx` | Word document generation |
-| `plotly` | Interactive charts |
-
-### Optional Dependencies
-
-| Package | Purpose | Install |
-|---------|---------|---------|
-| `weasyprint` | PDF generation | `uv sync --extra pdf` |
-| `markdown` | Markdown to HTML conversion | `uv sync --extra pdf` |
+| `MudBlazor` | Material Design component library for Blazor |
+| `Azure.Identity` | Azure authentication (DefaultAzureCredential) |
+| `Azure.Core` | Azure SDK core library |
+| `Plotly.Blazor` | Interactive charts |
+| `ClosedXML` | Excel document generation |
+| `CsvHelper` | CSV report generation |
## 🔒 Security
@@ -421,8 +384,8 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
## 🙏 Acknowledgments
- [Azure AI Services](https://azure.microsoft.com/products/ai-services/) for powerful AI capabilities
-- [Streamlit](https://streamlit.io/) for the intuitive web framework
-- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) for multi-agent orchestration
+- [MudBlazor](https://mudblazor.com/) for the Material Design component library
+- [.NET Blazor](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor) for the interactive web framework
## 📞 Support
@@ -432,4 +395,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
---
-**Built with ❤️ using Azure AI Services**
+**Built with ❤️ using .NET 10 and Azure AI Services**
From e1d0638e5dc6eeb2b08b51fd109a64f1f16893b9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 19:41:53 +0000
Subject: [PATCH 07/17] Fix static asset serving, update IaC for .NET 10, add
tests and README
Co-authored-by: amgdy <1763289+amgdy@users.noreply.github.com>
---
EfpAnalyzer/EfpAnalyzer/Components/App.razor | 4 ++--
EfpAnalyzer/EfpAnalyzer/Program.cs | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/EfpAnalyzer/EfpAnalyzer/Components/App.razor b/EfpAnalyzer/EfpAnalyzer/Components/App.razor
index 05d3925..24d7ff2 100644
--- a/EfpAnalyzer/EfpAnalyzer/Components/App.razor
+++ b/EfpAnalyzer/EfpAnalyzer/Components/App.razor
@@ -5,7 +5,7 @@
-
+
@@ -15,7 +15,7 @@
-
+