diff --git a/JobTrackerApi.Tests/GmailControllerTests.cs b/JobTrackerApi.Tests/GmailControllerTests.cs index e3fab2d..353dfef 100644 --- a/JobTrackerApi.Tests/GmailControllerTests.cs +++ b/JobTrackerApi.Tests/GmailControllerTests.cs @@ -498,6 +498,14 @@ public sealed class GmailControllerTests await db.SaveChangesAsync(); var gmail = new Mock(); + gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .ReturnsAsync(new GmailConnection + { + OwnerUserId = "user-1", + GmailAddress = "user@example.test", + ConnectedAt = DateTimeOffset.UtcNow.AddDays(-1), + Scope = "gmail.readonly" + }); gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny>(), 6, It.IsAny())) .ReturnsAsync(new[] { diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index 1bc9a9f..9a7c16b 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -239,6 +239,10 @@ public sealed class GmailController : ControllerBase CancellationToken cancellationToken = default) { var ownerUserId = GetRequiredOwnerUserId(); + if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null) + { + return GmailNotConnectedResult(); + } var jobs = await _db.JobApplications .Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted) .Include(x => x.Company) @@ -426,6 +430,10 @@ public sealed class GmailController : ControllerBase public async Task> ManualSync([FromBody] GmailManualSyncRequest? request, CancellationToken cancellationToken) { var ownerUserId = GetRequiredOwnerUserId(); + if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null) + { + return GmailNotConnectedResult(); + } var jobs = await _db.JobApplications .Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted) .Include(x => x.Company) @@ -552,6 +560,10 @@ public sealed class GmailController : ControllerBase public async Task> SuggestedJobs(CancellationToken cancellationToken) { var ownerUserId = GetRequiredOwnerUserId(); + if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null) + { + return GmailNotConnectedResult(); + } var reviewThreads = await ReviewCandidates(null, 6, cancellationToken); if (reviewThreads.Result is not OkObjectResult ok || ok.Value is not GmailReviewQueueResponseDto payload) { @@ -1084,6 +1096,20 @@ public sealed class GmailController : ControllerBase ?? throw new InvalidOperationException("Authenticated user id is missing."); } + private async Task GetOwnerGmailConnectionAsync(string ownerUserId, CancellationToken cancellationToken) + { + return await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); + } + + private ActionResult GmailNotConnectedResult() + { + return Conflict(new + { + code = "gmail_not_connected", + message = "Connect Gmail before using the Gmail review queue.", + }); + } + private string GetRedirectUri() { var configured = (_cfg["Google:GmailRedirectUri"] ?? _cfg["Google:RedirectUri"] ?? "").Trim(); diff --git a/JobTrackerApi/Services/SummarizerService.cs b/JobTrackerApi/Services/SummarizerService.cs index 1ae180c..76d8070 100644 --- a/JobTrackerApi/Services/SummarizerService.cs +++ b/JobTrackerApi/Services/SummarizerService.cs @@ -75,6 +75,7 @@ namespace JobTrackerApi.Services public class SummarizerService : ISummarizerService { + private const int AiSummarizeMaxInputChars = 20000; private readonly IHttpClientFactory _httpFactory; private readonly IMemoryCache _cache; private readonly object _metricsLock = new(); @@ -119,10 +120,27 @@ namespace JobTrackerApi.Services public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) { if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult(null); - var composed = $"{instruction.Trim()}\n\n{text.Trim()}"; + var composed = ComposeBoundedPrompt(instruction.Trim(), text.Trim()); return SummarizeCoreAsync(composed, maxLength, minLength); } + private static string ComposeBoundedPrompt(string instruction, string text) + { + var prefix = $"{instruction}\n\n"; + if (prefix.Length >= AiSummarizeMaxInputChars) + { + return prefix[..AiSummarizeMaxInputChars]; + } + + var remaining = AiSummarizeMaxInputChars - prefix.Length; + if (text.Length <= remaining) + { + return prefix + text; + } + + return prefix + text[..remaining]; + } + private async Task SummarizeCoreAsync(string text, int maxLength, int minLength) { var key = BuildCacheKey(text, maxLength, minLength); @@ -151,7 +169,17 @@ namespace JobTrackerApi.Services var res = await client.PostAsync("/summarize", content); sw.Stop(); Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks); - if (!res.IsSuccessStatusCode) return null; + if (!res.IsSuccessStatusCode) + { + var errorBody = await res.Content.ReadAsStringAsync(); + Interlocked.Increment(ref _failures); + lock (_metricsLock) + { + _lastFailureAt = DateTimeOffset.UtcNow; + _lastError = $"AI summarize returned {(int)res.StatusCode}: {errorBody}"; + } + return null; + } using var stream = await res.Content.ReadAsStreamAsync(); using var doc = await JsonDocument.ParseAsync(stream);