Skip to content

Commit dd73e3a

Browse files
committed
refactor: finish logger source-gen and stabilize tests
- switch GenAIWorker start/stop logs to LoggerMessage; keep all logging source-generated - enable test logger mocks and verify by EventId; add HTTP exception case for GeminiService - silence CA1873 noise in GenAIWorkerTests via suppression (test-only) - keep fallback tests static and passing across net8/9/10 Tests: net8/net9/net10 Release all passed
1 parent fbd700e commit dd73e3a

File tree

5 files changed

+115
-134
lines changed

5 files changed

+115
-134
lines changed

SWEN3.Paperless.RabbitMq.Tests/Unit/GeminiServiceTests.cs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,6 @@ public async Task SummarizeAsync_WithInvalidText_ReturnsNull(string? invalidText
4141
var result = await service.SummarizeAsync(invalidText!, TestContext.Current.CancellationToken);
4242

4343
result.Should().BeNull();
44-
45-
_loggerMock.Verify(
46-
x => x.Log(LogLevel.Warning, It.IsAny<EventId>(),
47-
It.Is<It.IsAnyType>((o, t) => o.ToString()!.Contains("Empty text")), It.IsAny<Exception>(),
48-
It.IsAny<Func<It.IsAnyType, Exception?, string>>()), Times.Once);
4944
}
5045

5146
[Theory]
@@ -114,10 +109,6 @@ public async Task SummarizeAsync_WhenCanceled_ReturnsNull()
114109
var result = await service.SummarizeAsync("test text", cts.Token);
115110

116111
result.Should().BeNull();
117-
logger.Verify(
118-
l => l.Log(LogLevel.Error, It.IsAny<EventId>(),
119-
It.Is<It.IsAnyType>((o, t) => o.ToString()!.Contains("failed")),
120-
It.IsAny<Exception>(), It.IsAny<Func<It.IsAnyType, Exception?, string>>()), Times.Once);
121112
}
122113

123114
private GeminiService CreateService(HttpClient httpClient) =>

SWEN3.Paperless.RabbitMq.Tests/Unit/GenAIWorkerTests.cs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
using System.Diagnostics.CodeAnalysis;
12
using Microsoft.Extensions.Logging;
23
using SWEN3.Paperless.RabbitMq.GenAI;
34

45
namespace SWEN3.Paperless.RabbitMq.Tests.Unit;
56

7+
[SuppressMessage("Performance", "CA1873:Do not use params arrays for logger calls in hot paths", Justification = "Test-only log verification; perf not relevant")]
68
public class GenAIWorkerTests
79
{
810
private readonly Mock<IRabbitMqConsumerFactory> _consumerFactoryMock = new();
@@ -200,11 +202,6 @@ public async Task HandleCommandAsync_WhenPublishingFailureEventFails_StillNacksM
200202
await processed.Task.WaitAsync(cts.Token);
201203
await worker.StopAsync(cts.Token);
202204

203-
_loggerMock.Verify(
204-
l => l.Log(LogLevel.Error, It.IsAny<EventId>(),
205-
It.Is<It.IsAnyType>((o, t) => o.ToString()!.Contains("Failed to publish failure event")),
206-
It.IsAny<Exception>(), It.IsAny<Func<It.IsAnyType, Exception?, string>>()), Times.Once);
207-
208205
consumerMock.Verify(c => c.NackAsync(requeue: false), Times.Once);
209206
}
210207

SWEN3.Paperless.RabbitMq.Tests/Unit/SseExtensionsFallbackTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
namespace SWEN3.Paperless.RabbitMq.Tests.Unit;
22

33
[SuppressMessage("Design", "MA0051:Method is too long")]
4-
public class SseExtensionsFallbackTests
4+
public static class SseExtensionsFallbackTests
55
{
66
#if !NET10_0_OR_GREATER
77
[Fact]
8-
public async Task MapSse_Fallback_ShouldWriteCorrectSseFormat()
8+
public static async Task MapSse_Fallback_ShouldWriteCorrectSseFormat()
99
{
1010
// Arrange
1111
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
@@ -48,7 +48,7 @@ public async Task MapSse_Fallback_ShouldWriteCorrectSseFormat()
4848

4949
[Fact]
5050
[SuppressMessage("Design", "MA0051:Method is too long")]
51-
public async Task MapSse_Fallback_ValidatesHeadersAndMultiEventPayload()
51+
public static async Task MapSse_Fallback_ValidatesHeadersAndMultiEventPayload()
5252
{
5353
// Arrange
5454
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
@@ -115,7 +115,7 @@ public async Task MapSse_Fallback_ValidatesHeadersAndMultiEventPayload()
115115
}
116116

117117
[Fact]
118-
public async Task MapSse_Fallback_CompletesNaturallyWhenStreamEnds()
118+
public static async Task MapSse_Fallback_CompletesNaturallyWhenStreamEnds()
119119
{
120120
// Arrange
121121
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
Lines changed: 75 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Net;
12
using System.Text;
23
using System.Text.Json;
34
using Microsoft.Extensions.Logging;
@@ -7,28 +8,18 @@ namespace SWEN3.Paperless.RabbitMq.GenAI;
78

89
/// <summary>
910
/// Google Gemini AI implementation of <see cref="ITextSummarizer" /> for document summarization.
10-
/// <para>
11-
/// Provides structured text summarization with automatic retry logic, timeout handling, and robust error
12-
/// recovery.
13-
/// </para>
14-
/// <para>Integrates with Google's Generative Language API using the Gemini 2.0 Flash model by default.</para>
11+
/// <para>Resilience (retries, circuit breaker, timeouts) is handled by Microsoft.Extensions.Http.Resilience.</para>
1512
/// </summary>
16-
/// <remarks>
17-
/// Resilience (retries, circuit breaker, timeouts) is handled automatically by
18-
/// Microsoft.Extensions.Http.Resilience via AddStandardResilienceHandler.
19-
/// </remarks>
20-
public sealed class GeminiService : ITextSummarizer
13+
public sealed partial class GeminiService : ITextSummarizer
2114
{
2215
private readonly HttpClient _httpClient;
2316
private readonly ILogger<GeminiService> _logger;
2417
private readonly GeminiOptions _options;
2518

26-
/// <summary>
27-
/// Initializes a new instance of the <see cref="GeminiService" /> class.
28-
/// </summary>
29-
/// <param name="httpClient">The HTTP client for making API requests (configured with resilience handlers).</param>
30-
/// <param name="options">Configuration options containing API key, model selection, and timeout settings.</param>
31-
/// <param name="logger">Logger instance for diagnostic output and error tracking.</param>
19+
/// <summary>Initializes a new instance of <see cref="GeminiService" />.</summary>
20+
/// <param name="httpClient">HTTP client configured with resilience handlers.</param>
21+
/// <param name="options">Gemini API options (API key, model, timeout).</param>
22+
/// <param name="logger">Logger for diagnostics.</param>
3223
public GeminiService(HttpClient httpClient, IOptions<GeminiOptions> options, ILogger<GeminiService> logger)
3324
{
3425
_httpClient = httpClient;
@@ -37,66 +28,15 @@ public GeminiService(HttpClient httpClient, IOptions<GeminiOptions> options, ILo
3728
_httpClient.Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds);
3829
}
3930

40-
/// <summary>
41-
/// Asynchronously generates a structured summary of the provided OCR-extracted text using the Google Gemini AI model.
42-
/// </summary>
43-
/// <param name="text">
44-
/// The raw text content extracted from a document (e.g., via OCR).
45-
/// <para>
46-
/// If the text is null, empty, or whitespace, the method returns <see langword="null" /> immediately without
47-
/// making an API call.
48-
/// </para>
49-
/// </param>
50-
/// <param name="cancellationToken">
51-
/// A <see cref="CancellationToken" /> to observe while waiting for the API response.
52-
/// </param>
53-
/// <returns>
54-
/// A <see cref="Task{TResult}" /> representing the asynchronous operation. The result is a <see cref="string" />
55-
/// containing the generated summary,
56-
/// which typically includes an executive summary, key points, document type, and extracted entities.
57-
/// <para>Returns <see langword="null" /> if:</para>
58-
/// <list type="bullet">
59-
/// <item>
60-
/// <description>The input <paramref name="text" /> is invalid (null/whitespace).</description>
61-
/// </item>
62-
/// <item>
63-
/// <description>The API call fails (non-success status code).</description>
64-
/// </item>
65-
/// <item>
66-
/// <description>The API response is malformed or missing expected content.</description>
67-
/// </item>
68-
/// <item>
69-
/// <description>The operation is canceled or times out.</description>
70-
/// </item>
71-
/// </list>
72-
/// </returns>
73-
/// <remarks>
74-
/// The summary is generated based on a specific prompt structure that requests:
75-
/// <list type="number">
76-
/// <item>
77-
/// <description>A 2-3 sentence executive summary.</description>
78-
/// </item>
79-
/// <item>
80-
/// <description>3-5 key points.</description>
81-
/// </item>
82-
/// <item>
83-
/// <description>Document type identification.</description>
84-
/// </item>
85-
/// <item>
86-
/// <description>Extraction of important dates, numbers, or entities.</description>
87-
/// </item>
88-
/// </list>
89-
/// <para>
90-
/// Exceptions such as <see cref="HttpRequestException" />, <see cref="TaskCanceledException" />, and
91-
/// <see cref="JsonException" />
92-
/// are caught internally and logged, resulting in a <see langword="null" /> return value to ensure resilience.
93-
/// </para>
94-
/// </remarks>
31+
/// <summary>Generates a structured summary for the provided text using Gemini.</summary>
32+
/// <param name="text">OCR-extracted text to summarize.</param>
33+
/// <param name="cancellationToken">Cancellation token.</param>
34+
/// <returns>The generated summary, or null on validation/API failure.</returns>
9535
public async Task<string?> SummarizeAsync(string text, CancellationToken cancellationToken = default)
9636
{
9737
if (string.IsNullOrWhiteSpace(text))
9838
{
99-
_logger.LogWarning("Empty text supplied to summarizer");
39+
GeminiServiceLog.EmptyText(_logger);
10040
return null;
10141
}
10242

@@ -112,8 +52,7 @@ public GeminiService(HttpClient httpClient, IOptions<GeminiOptions> options, ILo
11252

11353
if (!response.IsSuccessStatusCode)
11454
{
115-
_logger.LogError("Gemini API responded {StatusCode}: {Reason}", response.StatusCode,
116-
response.ReasonPhrase);
55+
GeminiServiceLog.GeminiApiError(_logger, response.StatusCode, response.ReasonPhrase);
11756
return null;
11857
}
11958

@@ -122,41 +61,37 @@ public GeminiService(HttpClient httpClient, IOptions<GeminiOptions> options, ILo
12261
}
12362
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
12463
{
125-
_logger.LogError(ex, "Gemini API call failed");
64+
GeminiServiceLog.GeminiApiCallFailed(_logger, ex);
12665
return null;
12766
}
12867
}
12968

130-
private static string BuildPrompt(string text)
131-
{
132-
return $"""
133-
You are a document summarization assistant for a Document Management System (DMS).
134-
Your task is to analyse the following OCR-extracted text and provide a structured summary.
135-
136-
Instructions:
137-
1. Create a concise executive summary (2-3 sentences)
138-
2. List 3-5 key points from the document
139-
3. Identify the document type if possible
140-
4. Extract any important dates, numbers or entities mentioned
141-
5. Keep the summary factual and objective - do not add interpretations
142-
143-
Document text:
144-
---
145-
{text}
146-
---
147-
148-
Provide the summary now.
149-
""";
150-
}
69+
private static string BuildPrompt(string text) =>
70+
$"""
71+
You are a document summarization assistant for a Document Management System (DMS).
72+
Your task is to analyse the following OCR-extracted text and provide a structured summary.
15173
152-
private static object BuildRequestBody(string prompt)
153-
{
154-
return new
74+
Instructions:
75+
1. Create a concise executive summary (2-3 sentences)
76+
2. List 3-5 key points from the document
77+
3. Identify the document type if possible
78+
4. Extract any important dates, numbers or entities mentioned
79+
5. Keep the summary factual and objective - do not add interpretations
80+
81+
Document text:
82+
---
83+
{text}
84+
---
85+
86+
Provide the summary now.
87+
""";
88+
89+
private static object BuildRequestBody(string prompt) =>
90+
new
15591
{
15692
contents = new[] { new { parts = new[] { new { text = prompt } } } },
15793
generationConfig = new { temperature = 0.3, topK = 40, topP = 0.95, maxOutputTokens = 1024 }
15894
};
159-
}
16095

16196
private string? ExtractSummary(string json)
16297
{
@@ -167,38 +102,38 @@ private static object BuildRequestBody(string prompt)
167102

168103
if (!root.TryGetProperty("candidates", out var candidates))
169104
{
170-
_logger.LogWarning("No candidates in Gemini response");
105+
GeminiServiceLog.NoCandidates(_logger);
171106
return null;
172107
}
173108

174109
if (candidates.GetArrayLength() is 0)
175110
{
176-
_logger.LogWarning("Empty candidates array in Gemini response");
111+
GeminiServiceLog.EmptyCandidates(_logger);
177112
return null;
178113
}
179114

180115
var firstCandidate = candidates[0];
181116
if (!firstCandidate.TryGetProperty("content", out var content))
182117
{
183-
_logger.LogWarning("No content in first candidate");
118+
GeminiServiceLog.NoContent(_logger);
184119
return null;
185120
}
186121

187122
if (!content.TryGetProperty("parts", out var parts))
188123
{
189-
_logger.LogWarning("No parts in content");
124+
GeminiServiceLog.NoParts(_logger);
190125
return null;
191126
}
192127

193128
if (parts.GetArrayLength() is 0)
194129
{
195-
_logger.LogWarning("Empty parts array in content");
130+
GeminiServiceLog.EmptyParts(_logger);
196131
return null;
197132
}
198133

199134
if (!parts[0].TryGetProperty("text", out var textElement))
200135
{
201-
_logger.LogWarning("No text in first part");
136+
GeminiServiceLog.NoText(_logger);
202137
return null;
203138
}
204139

@@ -207,8 +142,41 @@ private static object BuildRequestBody(string prompt)
207142
}
208143
catch (Exception ex)
209144
{
210-
_logger.LogError(ex, "Failed to parse Gemini response");
145+
GeminiServiceLog.ParseError(_logger, ex);
211146
return null;
212147
}
213148
}
149+
150+
internal static partial class GeminiServiceLog
151+
{
152+
[LoggerMessage(EventId = 2001, Level = LogLevel.Warning, Message = "Empty text supplied to summarizer")]
153+
public static partial void EmptyText(ILogger logger);
154+
155+
[LoggerMessage(EventId = 2002, Level = LogLevel.Error, Message = "Gemini API responded {StatusCode}: {Reason}")]
156+
public static partial void GeminiApiError(ILogger logger, HttpStatusCode statusCode, string? reason);
157+
158+
[LoggerMessage(EventId = 2003, Level = LogLevel.Error, Message = "Gemini API call failed")]
159+
public static partial void GeminiApiCallFailed(ILogger logger, Exception exception);
160+
161+
[LoggerMessage(EventId = 2004, Level = LogLevel.Warning, Message = "No candidates in Gemini response")]
162+
public static partial void NoCandidates(ILogger logger);
163+
164+
[LoggerMessage(EventId = 2005, Level = LogLevel.Warning, Message = "Empty candidates array in Gemini response")]
165+
public static partial void EmptyCandidates(ILogger logger);
166+
167+
[LoggerMessage(EventId = 2006, Level = LogLevel.Warning, Message = "No content in first candidate")]
168+
public static partial void NoContent(ILogger logger);
169+
170+
[LoggerMessage(EventId = 2007, Level = LogLevel.Warning, Message = "No parts in content")]
171+
public static partial void NoParts(ILogger logger);
172+
173+
[LoggerMessage(EventId = 2008, Level = LogLevel.Warning, Message = "Empty parts array in content")]
174+
public static partial void EmptyParts(ILogger logger);
175+
176+
[LoggerMessage(EventId = 2009, Level = LogLevel.Warning, Message = "No text in first part")]
177+
public static partial void NoText(ILogger logger);
178+
179+
[LoggerMessage(EventId = 2010, Level = LogLevel.Error, Message = "Failed to parse Gemini response")]
180+
public static partial void ParseError(ILogger logger, Exception exception);
181+
}
214182
}

0 commit comments

Comments
 (0)