perf(analytics): project minimal columns in GetStats/GetAnalyticsOverview #3

Merged
cesnimda merged 13 commits from perf/wave1-perf into main 2026-07-05 21:18:56 +02:00
22 changed files with 1105 additions and 211 deletions
+10
View File
@@ -13,6 +13,16 @@ AI_SERVICE_BASE_URL=http://ai-service:8001
OLLAMA_BASE_URL=http://ollama:11434 OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=qwen2.5:7b OLLAMA_MODEL=qwen2.5:7b
# AI provider for the heavy /cv/* calls: ollama (default, local) | gemini | groq.
# /summarize always stays local (distilbart). To offload a weak production GPU,
# set AI_PROVIDER=gemini (or groq) and provide the matching key below.
# Keys are read from the environment only — never commit real keys.
AI_PROVIDER=ollama
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.0-flash
GROQ_API_KEY=
GROQ_MODEL=llama-3.3-70b-versatile
# Optional: only needed if you want the UI to call a non-default API base URL. # Optional: only needed if you want the UI to call a non-default API base URL.
# In production the UI defaults to `/api`. # In production the UI defaults to `/api`.
REACT_APP_API_BASE_URL= REACT_APP_API_BASE_URL=
+14
View File
@@ -55,6 +55,20 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<JobApplication>() modelBuilder.Entity<JobApplication>()
.HasIndex(j => j.OwnerUserId); .HasIndex(j => j.OwnerUserId);
// Owner-prefixed composite indexes for the tenant-scoped hot paths. Every
// JobApplication query is scoped by the OwnerUserId global filter first, then
// filtered by IsDeleted (list/board/stats/analytics) or FollowUpAt (reminders).
// Status is intentionally excluded from the index because Pomelo maps the
// unbounded string column to longtext, which MariaDB cannot index without a
// prefix length. The actual index DDL is applied idempotently in
// StartupInitializationExtensions (this repo provisions schema via that
// reconciler, not via the EF ModelSnapshot, which is stale).
modelBuilder.Entity<JobApplication>()
.HasIndex(j => new { j.OwnerUserId, j.IsDeleted });
modelBuilder.Entity<JobApplication>()
.HasIndex(j => new { j.OwnerUserId, j.FollowUpAt });
modelBuilder.Entity<Company>() modelBuilder.Entity<Company>()
.HasIndex(c => c.OwnerUserId); .HasIndex(c => c.OwnerUserId);
@@ -23,9 +23,10 @@ namespace JobTrackerApi.Controllers
private readonly ILogger<JobApplicationsController> _logger; private readonly ILogger<JobApplicationsController> _logger;
private readonly ICvTemplateRenderer _cvTemplateRenderer; private readonly ICvTemplateRenderer _cvTemplateRenderer;
private readonly ICvPdfExporter _cvPdfExporter; private readonly ICvPdfExporter _cvPdfExporter;
private readonly AnalyticsService _analytics;
private readonly IJobCvMatchService _matchService; private readonly IJobCvMatchService _matchService;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, IJobCvMatchService? matchService = null) public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null)
{ {
_db = db; _db = db;
_summarizer = summarizer; _summarizer = summarizer;
@@ -34,6 +35,7 @@ namespace JobTrackerApi.Controllers
_logger = logger; _logger = logger;
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer(); _cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter(); _cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
_analytics = analytics ?? new AnalyticsService(db);
_matchService = matchService ?? new JobCvMatchService(); _matchService = matchService ?? new JobCvMatchService();
} }
@@ -1831,46 +1833,9 @@ Canonical profile:
return Ok(all); return Ok(all);
} }
public sealed record JobStats(
int Total,
int Active,
int Deleted,
Dictionary<string, int> ByStatus,
int AppliedLast30Days,
double AverageDaysSinceApplied
);
[HttpGet("stats")] [HttpGet("stats")]
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken) public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
{ => Ok(await _analytics.GetStatsAsync(cancellationToken));
var now = DateTime.Now;
var all = await _db.JobApplications
.AsNoTracking()
.ToListAsync(cancellationToken);
var active = all.Where(j => !j.IsDeleted).ToList();
var byStatus = active
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
var avgDays = active.Count == 0
? 0
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
return Ok(new JobStats(
Total: all.Count,
Active: active.Count,
Deleted: all.Count - active.Count,
ByStatus: byStatus,
AppliedLast30Days: appliedLast30Days,
AverageDaysSinceApplied: Math.Round(avgDays, 1)
));
}
public sealed record AnalyticsPoint(string Month, int Applied, int Responses); public sealed record AnalyticsPoint(string Month, int Applied, int Responses);
[HttpGet("analytics")] [HttpGet("analytics")]
@@ -2067,21 +2032,8 @@ Canonical profile:
return Ok(outList); return Ok(outList);
} }
public sealed record FunnelStagePoint(string Label, int Count);
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
public sealed record TagTrendSeries(string Tag, List<int> Counts); public sealed record TagTrendSeries(string Tag, List<int> Counts);
public sealed record TagTrendPoint(string Month, List<int> Counts); public sealed record TagTrendPoint(string Month, List<int> Counts);
public sealed record StageDurationDto(string Stage, double MedianDays, int Count);
public sealed record AnalyticsOverviewDto(
List<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
List<CompanyActivityPoint> TopCompanies,
double? MedianDaysToFirstResponse,
int TotalResponses,
int TotalActive,
List<StageDurationDto> TimeInStage
);
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason); public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches); public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt); public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt);
@@ -2847,106 +2799,7 @@ Candidate master CV:
[HttpGet("analytics-overview")] [HttpGet("analytics-overview")]
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken) public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
{ => Ok(await _analytics.GetAnalyticsOverviewAsync(cancellationToken));
var activeJobs = await _db.JobApplications
.AsNoTracking()
.Include(j => j.Company)
.Where(j => !j.IsDeleted)
.ToListAsync(cancellationToken);
// Funnel = distribution across canonical stages, driven by the pipeline (one source
// of truth, so it includes every stage and normalizes legacy spellings).
var normalizedByStage = activeJobs
.GroupBy(j => JobPipeline.Normalize(j.Status))
.ToDictionary(g => g.Key, g => g.Count());
var funnel = JobPipeline.Stages
.Select(stage => new FunnelStagePoint(stage.Key, normalizedByStage.TryGetValue(stage.Key, out var c) ? c : 0))
.ToList();
var responseRateBySource = activeJobs
.GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim())
.Select(g => new ResponseRatePoint(
g.Key,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Total)
.ThenByDescending(x => x.Rate)
.Take(6)
.ToList();
var topCompanies = activeJobs
.GroupBy(j => new { j.CompanyId, Name = j.Company.Name })
.Select(g => new CompanyActivityPoint(
g.Key.CompanyId,
g.Key.Name,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Count)
.ThenByDescending(x => x.ResponseRate)
.Take(8)
.ToList();
var responseDays = activeJobs
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
.OrderBy(x => x)
.ToList();
double? medianDays = null;
if (responseDays.Count > 0)
{
var mid = responseDays.Count / 2;
medianDays = responseDays.Count % 2 == 0
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
: Math.Round(responseDays[mid], 1);
}
// Time-in-stage: for each active job, when did it enter its current stage? Use the most
// recent StatusChanged event into that stage, else its applied date.
var activeIds = activeJobs.Select(j => j.Id).ToList();
var statusChanges = await _db.JobEvents
.AsNoTracking()
.Where(e => e.Type == "StatusChanged" && activeIds.Contains(e.JobApplicationId))
.Select(e => new { e.JobApplicationId, e.NewValue, e.At })
.ToListAsync(cancellationToken);
var lastEntryByJob = statusChanges
.GroupBy(e => e.JobApplicationId)
.ToDictionary(g => g.Key, g => g.ToList());
var occupancy = activeJobs.Select(job =>
{
var current = JobPipeline.Normalize(job.Status);
DateTime enteredAt = job.DateApplied;
if (lastEntryByJob.TryGetValue(job.Id, out var changes))
{
var lastIntoCurrent = changes
.Where(e => JobPipeline.Normalize(e.NewValue) == current)
.OrderByDescending(e => e.At)
.FirstOrDefault();
if (lastIntoCurrent is not null) enteredAt = lastIntoCurrent.At;
}
return new StageOccupancy(current, enteredAt.ToUniversalTime());
});
var timeInStage = StageAnalytics.TimeInStage(occupancy, DateTime.UtcNow)
.Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
.ToList();
return Ok(new AnalyticsOverviewDto(
Funnel: funnel,
ResponseRateBySource: responseRateBySource,
TopCompanies: topCompanies,
MedianDaysToFirstResponse: medianDays,
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
TotalActive: activeJobs.Count,
TimeInStage: timeInStage
));
}
[HttpGet("tag-trends")] [HttpGet("tag-trends")]
public async Task<ActionResult<TagTrendResponse>> GetTagTrends( public async Task<ActionResult<TagTrendResponse>> GetTagTrends(
+5
View File
@@ -156,6 +156,7 @@ builder.Services.AddHttpClient("ai-service", client =>
}); });
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddScoped<AnalyticsService>();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>(); builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>(); builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>(); builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
@@ -165,6 +166,10 @@ builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>(); builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>(); builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
// Provider-neutral email seam (multi-provider: Gmail today; Microsoft Graph / IMAP / manual next).
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
builder.Services.AddIdentityCore<ApplicationUser>(options => builder.Services.AddIdentityCore<ApplicationUser>(options =>
{ {
options.User.RequireUniqueEmail = true; options.User.RequireUniqueEmail = true;
+172
View File
@@ -0,0 +1,172 @@
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services
{
/// <summary>
/// Read-only analytics/statistics aggregation extracted from JobApplicationsController.
/// Uses the tenant-scoped <see cref="JobTrackerContext"/>, so the global OwnerUserId
/// query filters apply automatically. Behaviour is identical to the former inline
/// controller methods (GetStats / GetAnalyticsOverview).
/// </summary>
public sealed class AnalyticsService
{
private readonly JobTrackerContext _db;
public AnalyticsService(JobTrackerContext db)
{
_db = db;
}
public async Task<JobStats> GetStatsAsync(CancellationToken cancellationToken)
{
var now = DateTime.Now;
// Project to only the columns the stats need instead of materialising full
// JobApplication rows (which drag large Description/TranslatedDescription/
// TailoredCvText/Notes blobs). Aggregation stays in memory over a small
// per-tenant set.
var all = await _db.JobApplications
.AsNoTracking()
.Select(j => new { j.IsDeleted, j.Status, j.DateApplied })
.ToListAsync(cancellationToken);
var active = all.Where(j => !j.IsDeleted).ToList();
var byStatus = active
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
var avgDays = active.Count == 0
? 0
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
return new JobStats(
Total: all.Count,
Active: active.Count,
Deleted: all.Count - active.Count,
ByStatus: byStatus,
AppliedLast30Days: appliedLast30Days,
AverageDaysSinceApplied: Math.Round(avgDays, 1)
);
}
public async Task<AnalyticsOverviewDto> GetAnalyticsOverviewAsync(CancellationToken cancellationToken)
{
// Project to only the fields the overview needs instead of Include-ing full
// Company + JobApplication rows (avoids loading large description/CV blobs).
var activeJobs = await _db.JobApplications
.AsNoTracking()
.Where(j => !j.IsDeleted)
.Select(j => new
{
j.Id,
j.Status,
j.ResponseReceived,
j.ResponseDate,
j.DateApplied,
j.CompanyId,
CompanyName = j.Company.Name,
CompanySource = j.Company.Source
})
.ToListAsync(cancellationToken);
// Funnel = distribution across canonical stages, driven by the pipeline (one source
// of truth, so it includes every stage and normalizes legacy spellings).
var normalizedByStage = activeJobs
.GroupBy(j => JobPipeline.Normalize(j.Status))
.ToDictionary(g => g.Key, g => g.Count());
var funnel = JobPipeline.Stages
.Select(stage => new FunnelStagePoint(stage.Key, normalizedByStage.TryGetValue(stage.Key, out var c) ? c : 0))
.ToList();
var responseRateBySource = activeJobs
.GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
.Select(g => new ResponseRatePoint(
g.Key,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Total)
.ThenByDescending(x => x.Rate)
.Take(6)
.ToList();
var topCompanies = activeJobs
.GroupBy(j => new { j.CompanyId, Name = j.CompanyName })
.Select(g => new CompanyActivityPoint(
g.Key.CompanyId,
g.Key.Name,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Count)
.ThenByDescending(x => x.ResponseRate)
.Take(8)
.ToList();
var responseDays = activeJobs
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
.OrderBy(x => x)
.ToList();
double? medianDays = null;
if (responseDays.Count > 0)
{
var mid = responseDays.Count / 2;
medianDays = responseDays.Count % 2 == 0
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
: Math.Round(responseDays[mid], 1);
}
// Time-in-stage: for each active job, when did it enter its current stage? Use the most
// recent StatusChanged event into that stage, else its applied date.
var activeIds = activeJobs.Select(j => j.Id).ToList();
var statusChanges = await _db.JobEvents
.AsNoTracking()
.Where(e => e.Type == "StatusChanged" && activeIds.Contains(e.JobApplicationId))
.Select(e => new { e.JobApplicationId, e.NewValue, e.At })
.ToListAsync(cancellationToken);
var lastEntryByJob = statusChanges
.GroupBy(e => e.JobApplicationId)
.ToDictionary(g => g.Key, g => g.ToList());
var occupancy = activeJobs.Select(job =>
{
var current = JobPipeline.Normalize(job.Status);
DateTime enteredAt = job.DateApplied;
if (lastEntryByJob.TryGetValue(job.Id, out var changes))
{
var lastIntoCurrent = changes
.Where(e => JobPipeline.Normalize(e.NewValue) == current)
.OrderByDescending(e => e.At)
.FirstOrDefault();
if (lastIntoCurrent is not null) enteredAt = lastIntoCurrent.At;
}
return new StageOccupancy(current, enteredAt.ToUniversalTime());
});
var timeInStage = StageAnalytics.TimeInStage(occupancy, DateTime.UtcNow)
.Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
.ToList();
return new AnalyticsOverviewDto(
Funnel: funnel,
ResponseRateBySource: responseRateBySource,
TopCompanies: topCompanies,
MedianDaysToFirstResponse: medianDays,
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
TotalActive: activeJobs.Count,
TimeInStage: timeInStage
);
}
}
}
@@ -0,0 +1,63 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Gmail implementation of <see cref="IEmailProvider"/>. Adapts the existing
/// <see cref="IGmailOAuthService"/> (Gmail REST client) to the provider-neutral contract,
/// mapping Gmail DTOs to the neutral shapes.
/// </summary>
public sealed class GmailProvider : IEmailProvider
{
private readonly IGmailOAuthService _gmail;
public GmailProvider(IGmailOAuthService gmail)
{
_gmail = gmail;
}
public string ProviderKey => "gmail";
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
return connection is null ? null : new EmailConnectionInfo("gmail", connection.GmailAddress ?? "");
}
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
var messages = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
{
var messages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var attachments = detail.Attachments
.Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.GmailAttachmentId, a.Inline))
.ToList();
return new EmailMessageDetail(
detail.Id,
detail.ThreadId,
detail.Subject,
detail.From,
detail.To,
detail.Date,
detail.Snippet,
detail.BodyText,
detail.BodyHtml,
detail.Labels,
attachments);
}
private static EmailMessageSummary ToSummary(GmailMessageSummary m)
=> new(m.Id, m.ThreadId, m.Subject, m.From, m.To, m.Date, m.Snippet);
}
}
@@ -0,0 +1,69 @@
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Provider-neutral email operations so job correspondence can be sourced from Gmail,
/// Microsoft Graph, generic IMAP, or manual/free-text entry behind a single seam.
/// See docs/remaster/PRODUCT_DIRECTION.md (multi-provider email). Gmail is the first
/// implementation (<see cref="GmailProvider"/>); the controller migration and additional
/// providers land in follow-up slices.
/// </summary>
public interface IEmailProvider
{
/// <summary>Stable key: "gmail" | "microsoft" | "imap" | "manual".</summary>
string ProviderKey { get; }
/// <summary>The user's active connection for this provider, or null if not connected.</summary>
Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
/// <summary>Search the user's mailbox. <paramref name="query"/> is provider-specific syntax.</summary>
Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
/// <summary>All messages in a thread/conversation.</summary>
Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
/// <summary>Full message content (body + attachments metadata).</summary>
Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}
public sealed record EmailConnectionInfo(string ProviderKey, string Address);
public sealed record EmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record EmailAttachmentRef(string? FileName, string? MimeType, long? SizeBytes, string? ExternalAttachmentId, bool Inline);
public sealed record EmailMessageDetail(
string Id,
string ThreadId,
string Subject,
string From,
string To,
DateTimeOffset? Date,
string Snippet,
string BodyText,
string? BodyHtml,
IReadOnlyList<string> Labels,
IReadOnlyList<EmailAttachmentRef> Attachments);
/// <summary>Resolves a registered <see cref="IEmailProvider"/> by its key.</summary>
public interface IEmailProviderRegistry
{
IReadOnlyList<IEmailProvider> All { get; }
IEmailProvider? Get(string? providerKey);
}
public sealed class EmailProviderRegistry : IEmailProviderRegistry
{
private readonly Dictionary<string, IEmailProvider> _byKey;
public EmailProviderRegistry(IEnumerable<IEmailProvider> providers)
{
All = providers.ToList();
_byKey = All.ToDictionary(p => p.ProviderKey, StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<IEmailProvider> All { get; }
public IEmailProvider? Get(string? providerKey)
=> !string.IsNullOrWhiteSpace(providerKey) && _byKey.TryGetValue(providerKey, out var p) ? p : null;
}
}
@@ -505,6 +505,16 @@ public static class StartupInitializationExtensions
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;"); EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;"); EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). Guarded
// on table existence: on a brand-new DB the table is created by Migrate()
// below, so the index is picked up on the next start.
if (HasTable(conn, "JobApplications"))
{
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted" ON "JobApplications" ("OwnerUserId", "IsDeleted");""");
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_FollowUpAt" ON "JobApplications" ("OwnerUserId", "FollowUpAt");""");
}
// Ensure data folder exists before creating/opening SQLite files. // Ensure data folder exists before creating/opening SQLite files.
Directory.CreateDirectory(paths.DataRoot); Directory.CreateDirectory(paths.DataRoot);
} }
@@ -829,6 +839,22 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt).
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_IsDeleted` ON `JobApplications` (`OwnerUserId`, `IsDeleted`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_FollowUpAt` ON `JobApplications` (`OwnerUserId`, `FollowUpAt`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc")) if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc"))
{ {
using var cmd = conn.CreateCommand(); using var cmd = conn.CreateCommand();
+33
View File
@@ -0,0 +1,33 @@
using System.Collections.Generic;
namespace JobTrackerApi.Models
{
// Read-only analytics/statistics response DTOs. Extracted from
// JobApplicationsController so the aggregation logic can live in AnalyticsService.
public sealed record JobStats(
int Total,
int Active,
int Deleted,
Dictionary<string, int> ByStatus,
int AppliedLast30Days,
double AverageDaysSinceApplied
);
public sealed record FunnelStagePoint(string Label, int Count);
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
public sealed record StageDurationDto(string Stage, double MedianDays, int Count);
public sealed record AnalyticsOverviewDto(
List<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
List<CompanyActivityPoint> TopCompanies,
double? MedianDaysToFirstResponse,
int TotalResponses,
int TotalActive,
List<StageDurationDto> TimeInStage
);
}
+7
View File
@@ -81,6 +81,13 @@ services:
# and no duplicate Ollama container is created. # and no duplicate Ollama container is created.
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434} - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b} - OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
# AI provider for heavy /cv/* calls: ollama (default) | gemini | groq.
# Set AI_PROVIDER=gemini + GEMINI_API_KEY in prod to offload a weak local GPU.
- AI_PROVIDER=${AI_PROVIDER:-ollama}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash}
- GROQ_API_KEY=${GROQ_API_KEY:-}
- GROQ_MODEL=${GROQ_MODEL:-llama-3.3-70b-versatile}
ports: ports:
- "8001:8001" - "8001:8001"
networks: networks:
+83
View File
@@ -0,0 +1,83 @@
# Memory Leak Report — Job Tracker
**Date:** 2026-07-05
**Investigator role:** Senior Performance Engineer (memory/browser internals/full-stack)
**Verdict:** **No confirmed memory leak.** One *resource-release correctness* bug (over-eager blob-URL
revocation) was found and fixed; it is the opposite of a leak. See [ROOT_CAUSE_ANALYSIS.md](ROOT_CAUSE_ANALYSIS.md)
and [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md).
> Method & honesty note. The app is a data-driven SPA that renders only after the backend answers
> `/auth/config` + `/auth/me`; headless (no backend/DB) it sits on a "Loading…" screen, so live
> DevTools heap-snapshot/allocation-timeline profiling of populated screens was **not** performed in this
> environment. Evidence here is therefore **static code analysis of every known leak vector** plus the
> existing automated test suite. Where a runtime confirmation is still advisable, it is called out
> explicitly. Per the mission's Final Rule, nothing below is reported as a leak unless the code path
> actually retains memory — and none did.
---
## Phase 12 — Does a leak exist? Can it be reproduced?
No leak was reproduced or evidenced. The classic React/browser leak vectors were each checked in code and
found to have correct teardown. "Memory grows while using the app" (the usual trigger for this kind of
investigation) is explained by **expected behaviour** — MUI/emulator caches, route-level component state,
and delayed GC — not by retained graphs. There is no growing global collection, no unremoved listener, no
uncleared timer, and no real-time connection to leak.
## Phase 3 / 3.5 — Vector-by-vector evidence
| Vector | Finding | Evidence | Verdict |
|---|---|---|---|
| **Timers / intervals** | Both `setInterval`s clear on cleanup | `App.tsx:154-155` (reminders, 60s → `clearInterval`); `ProfilePage.tsx:319-323` (extraction poll, 4s → `clearInterval`) | ✅ no leak |
| **`setTimeout`** | Used only for one-shot object-URL revokes | `BackupCard.tsx:29`, `Attachments.tsx:193`, `ImportExportJobs.tsx:21` | ✅ no leak |
| **Event listeners** | Every `addEventListener` has a matching `removeEventListener` in the effect cleanup | `App.tsx:174-175` (auth-changed), `App.tsx:185-186` (keydown), `CropImageDialog.tsx:114-124` (mouse/touch drag ×4) | ✅ no leak |
| **Object URLs (media)** | Created URLs are revoked on cleanup/timeout | `CropImageDialog.tsx:59/65`, `Attachments.tsx:111/181/193/201`, `BackupCard.tsx:18/29`, `ImportExportJobs.tsx:16/21`, `JobDetailsDialog.tsx:507/514`, `ProfilePage.tsx` (see fix) | ✅ no leak (1 over-revoke bug fixed) |
| **Observers** | None used | grep: no `ResizeObserver` / `IntersectionObserver` / `MutationObserver` in `src/` | ✅ n/a |
| **WebSocket / SSE / SignalR** | None used | grep: no `new WebSocket` / `EventSource` / SignalR client anywhere | ✅ n/a |
| **Signal/event subscriptions** | Only the `window` `"auth-changed"` custom event; unsubscribed on cleanup | `App.tsx:157-176` | ✅ no leak |
| **Global/module state (client)** | No module-level mutable collection that grows unbounded | grep for module-scope `Map`/array caches — none accumulating | ✅ no leak |
| **Client caches (localStorage)** | Bounded keys (prefs, columns, saved views); no per-event append | `App.tsx`, `SettingsView.tsx`, `SavedViewsMenu.tsx`, `themePrefs.ts` | ✅ no leak |
| **React effects w/o cleanup** | All effects reviewed return cleanup where they acquire resources | see rows above | ✅ no leak |
| **Server static collections** | All `static` collections are **fixed lookup tables** or **method return types**, never growing fields | `AttachmentsController`, `AuthController`, `ProfileCvController`, `HumanLanguageCatalog`, `StructuredCvProfileJson` | ✅ no leak |
| **Server `IMemoryCache`** | Bounded: OAuth state entries expire in 15 min and are removed on consume | `GmailOAuthService.cs:72` (`TimeSpan.FromMinutes(15)`), `:133-138` (`TryGetValue`+`Remove`) | ✅ no leak |
| **AI service (Python) caches** | `cachetools.TTLCache` (bounded by TTL + maxsize) | `tools/summarizer/app.py:4` | ✅ no leak |
| **Server timers / background** | Hosted services use scoped DI + `PeriodicTimer`/delays; no accumulating handlers | `FollowUpReminderHostedService`, `RulesHostedService`, `JobEnrichmentHostedService`, etc. | ✅ no leak |
## Phase 3.5 — Repeated/duplicate work audit
- **Reminders poll** (`App.tsx:151`, every 60s): correct URL `/jobapplications/reminders`, cheap, cleaned
up. (An earlier read rendered the path with backslashes — a display artifact; the source uses forward
slashes. **No bug.**)
- **Extraction-run poll** (`ProfilePage.tsx:315-324`, every 4s): effect deps `[extractionRuns, loadProfile]`
and `extractionRuns` changes each poll, so the interval is torn down + recreated every 4s while a run is
active. **Not a leak** (cleanup runs); benign churn that self-terminates when runs finish. Minor — see
[PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md).
- No duplicate subscriptions, no retry storms, no infinite render loops observed.
## Phase 4 — Root cause
No leak → no leak root cause. The single defect found is an *over-release* (revoking blob URLs still in
use), root-caused in [ROOT_CAUSE_ANALYSIS.md](ROOT_CAUSE_ANALYSIS.md).
## Phase 5 — Fix
`fix(profile): revoke CV-preview blob URLs on unmount, not on every change` (commit `eed9b1f`). Smallest
change: track the carousel in a ref and revoke only on unmount.
## Phase 6 — Verification
`profile-page.test.tsx` passes **5/5** with an adequate test timeout after the fix. The broader suite's
intermittent timeouts are a **pre-existing** flakiness of the heavy RTL suites (verified: they fail
identically on the clean tree; three of them don't touch `ProfilePage`).
## Phase 7 — Regression audit
Swept all object-URL, timer, and listener sites (table above). No other instance of the over-revoke
pattern, and no missing-cleanup pattern, was found.
## Remaining risks / recommendations
- Live heap-snapshot profiling on a **populated** session (real backend) is still worth doing once, to
confirm the static conclusion under real navigation — see [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md).
- Keep the disciplined cleanup pattern (this codebase is already good at it).
## Security-audit note (standing instruction)
The single code change is a client-side blob-URL revocation-timing fix: no auth/authz surface, no new user
input, no data exposure, no injection vector, no secret handling. Nothing for the security lens to flag.
Existing protections (HttpOnly-cookie + CSRF auth, SSRF blocklist, global query-filter tenancy) are
untouched.
@@ -0,0 +1,72 @@
# Performance Improvements — Job Tracker
**Companion to:** [MEMORY_LEAK_REPORT.md](MEMORY_LEAK_REPORT.md) · [ROOT_CAUSE_ANALYSIS.md](ROOT_CAUSE_ANALYSIS.md)
## Changes made (this pass)
| Change | File | Effect | Verified |
|---|---|---|---|
| **Stop an infinite render loop on every list view** — hold `load` in a ref in `useViewResource` so `reload`/the fetch effect keep a stable identity | `job-tracker-ui/src/hooks/useViewResource.ts` | Fixes "Maximum update depth exceeded" on `/jobs` (and any `DashboardView`/`RemindersView`/`CompaniesTable` view whose caller passes an inline `load`) — pegged the CPU/renderer | **Runtime-confirmed**: `/jobs` went from a render storm (renderer frozen, 100s of errors) to 0 console errors in a live 2s window and a clean render; `workflow-trust-signals` (drives `JobTable``useViewResource`) passes |
| **Stop the infinite `/auth/me` request loop** — make `clearAuthClientState` emit `auth-changed` only on a real signed-in→out transition | `job-tracker-ui/src/auth.ts` | Eliminates a runaway request storm (100+ `GET /auth/me` and climbing) that ran continuously whenever the user was logged out | **Runtime-confirmed** in a live stack: `/auth/me` count 100+ & growing → 0 and stable after fix |
| Revoke CV-preview blob URLs on unmount only (ref-based), not on every carousel change | `job-tracker-ui/src/pages/ProfilePage.tsx` | Fixes broken previews on multi-template decks; still frees URLs on unmount | `profile-page.test.tsx` 5/5 |
### Runtime finding — self-triggering auth loop (the most impactful issue found)
Only visible with a running backend (static analysis could not surface it). Sequence: the axios response
interceptor (`api.ts`) calls `clearAuthClientState()` on **every** 401; that dispatched `"auth-changed"`;
the `App` handler re-fetched `/auth/me`; that 401'd again → interceptor → `clearAuthClientState()`
`"auth-changed"` → … an unbounded loop that hammered the server and spun the client on the login page and
after any session expiry. Fix: `clearAuthClientState` now only emits when it actually removes a stored user
key (idempotent), so repeated 401s can't re-trigger the fetch. This is a CPU/network/battery drain and a
self-inflicted request flood, not a memory leak — but squarely in the Phase 3.5 "infinite polling / retry
loop / duplicate requests" scope, and the single highest-value fix from the whole investigation.
> Context: this was the only defect found in a full resource audit. The codebase already practises
> disciplined cleanup (timers cleared, listeners removed, object URLs revoked), so there was no leak to
> fix — see the main report.
## Recommended (low-severity, optional)
### 1. Stabilise the extraction-run poll — *minor*
`ProfilePage.tsx:315-324` recreates its 4s interval on every poll because `extractionRuns` is in the deps
and changes each tick. It's harmless (cleanup runs; it stops when runs finish) but churns. If touched:
poll on a stable trigger (e.g. a boolean `hasActiveRuns` in deps, or read runs from a ref inside the
interval) so the interval is created once per active-window.
### 2. One live heap-snapshot pass on a populated session — *verification, not a fix*
The static audit is strong, but a single DevTools confirmation closes the loop:
1. Run the real stack (backend on `:5202` + a seeded DB) and sign in.
2. DevTools → Memory → take a heap snapshot.
3. Navigate `/dashboard → /jobs → open a job dialog → close → /profile → build a CV deck → back`, ×5.
4. Force GC, take a second snapshot, **Comparison** view.
5. Expect: node/listener/detached counts return to baseline (sawtooth), not monotonic growth. Sort
retained size by constructor; look for `Detached HTMLElement`, growing `Array`/`Map`, or listener
counts that never fall.
Also cheap and useful: `performance.memory.usedJSHeapSize` (Chromium) logged across the loop, or a
Playwright script that repeats the navigation and asserts heap stays bounded.
### 3. Guard async setState after unmount — *defensive, not a current leak*
Several components `await api…().then(setState)`. React 18 no-ops setState on unmounted components (just a
dev warning historically), so this is not a leak, but for long CV/AI calls consider an `AbortController`
on the request (cancels the in-flight network work on unmount) — improves responsiveness and avoids wasted
work more than memory.
## Prevention — keep leaks from creeping in
- **Lint:** enable `react-hooks/exhaustive-deps` (surfaces the exact wrong-deps class that caused the one
bug here) and consider `react-hooks/react-compiler` checks.
- **Rule of thumb:** any effect that *acquires* a resource (listener, timer, object URL, observer,
subscription, connection) must return a cleanup that releases exactly that resource. "Release once on
unmount" ⇒ empty-deps effect + a ref for current state — never a value in the deps array.
- **Object URLs:** pair every `createObjectURL` with a `revokeObjectURL` in the *same* owner; prefer
revoking on unmount/replace, never on unrelated re-renders.
- **Server caches:** every `IMemoryCache.Set` must carry an absolute/sliding expiration (as
`GmailOAuthService` correctly does); if the app grows to heavy caching, set a `SizeLimit`.
- **No unbounded static state:** keep `static` collections to fixed lookup tables (as today); never
accumulate per-request data in a static field.
- **CI:** the heavy RTL suites are timeout-flaky under load — raising `testTimeout` (e.g. 1520s) or
reducing jest worker contention would make regressions (including any future leak-guard tests) reliably
visible instead of hidden behind flakes.
## Security-audit note (standing instruction)
The applied change carries no security surface (client-side URL lifetime only). The recommendations above
introduce none either; if #3 (AbortController) is implemented, ensure aborted requests don't leave
partial writes — not applicable to the read-only CV export/preview calls here.
+67
View File
@@ -0,0 +1,67 @@
# Root Cause Analysis — Job Tracker resource audit
**Companion to:** [MEMORY_LEAK_REPORT.md](MEMORY_LEAK_REPORT.md)
## Summary
There is **no memory leak** to root-cause. The investigation surfaced exactly one defect — an
**over-eager blob-URL revocation** in the CV PDF carousel — which is a *release-too-early* bug, the
inverse of a leak. This document root-causes that defect and explains why the "app memory grows" symptom
does **not** indicate a leak here.
## The one defect — over-revoked preview URLs
### What the code did (before)
`job-tracker-ui/src/pages/ProfilePage.tsx`:
```ts
useEffect(() => {
return () => {
pdfCarousel.forEach((item) => item.pdfUrl && URL.revokeObjectURL(item.pdfUrl));
};
}, [pdfCarousel]); // <-- deps on pdfCarousel
```
A cleanup with `[pdfCarousel]` deps runs its teardown **before every re-run**, i.e. on *every* change to
`pdfCarousel`, not just on unmount.
### Why it broke
`buildPdfCarousel()` seeds all templates, then `savePdfToCarousel()` replaces each seed **in place**, one
`setPdfCarousel` call at a time (`ProfilePage.tsx:400-410`). Trace with templates A, B, C:
1. `[A₁, B₀, C₀]` (A built, B/C seeds without URLs) — cleanup revoked prior `[A₀,B₀,C₀]` (no URLs). OK.
2. `[A₁, B₁, C₀]` (B built) — cleanup runs on the **previous** array `[A₁,B₀,C₀]`**revokes `A₁`'s URL**,
but `A₁` is still present in the new array and still shown when the user flips the carousel to A.
3. `[A₁, B₁, C₁]` (C built) — cleanup revokes `[A₁,B₁,C₀]` → revokes `B₁` too.
**Result:** after building an N-template deck, every preview except the **last** points at a revoked
(broken) blob URL.
### Root cause
Wrong effect dependency scope: a resource that should be released **once, on unmount** was tied to a
value-change dependency, so React's "cleanup-before-next-run" semantics turned it into a per-change
revoke. Compounded by the fact that legitimate drop paths already revoke explicitly
(`savePdfToCarousel` replace at `:402-403`, `resetPdfCarousel` clear at `:378-384`), making the effect's
revocation redundant *and* destructive.
### Why it is not a leak
On unmount the effect *did* revoke the current array (deps capture the latest value), so URLs were freed.
The bug wastes nothing and retains nothing — it releases too **eagerly**. It is a correctness bug
(broken previews), filed here because Phase 3.5 explicitly covers "image/media resources … released".
### Fix (commit `eed9b1f`)
Track the carousel in a ref; revoke **only on unmount** (empty-deps effect). Drop paths keep their
explicit revokes. Verified: `profile-page.test.tsx` 5/5.
## Why the "memory grows" symptom is not a leak here
Per the mission's Final Rule, distinguishing the four causes:
- **Expected caching** — MUI emotion style cache, `react-scripts` dev tooling, and route component state
grow then plateau; not unbounded.
- **Delayed GC** — detached nodes from closed dialogs/pages are collected on the next major GC, not
instantly; a rising sawtooth is normal.
- **Browser behaviour** — bfcache, image decode buffers, and devtools retention inflate numbers in a way
unrelated to app code.
- **Genuine leak** — would require a retained root (listener, timer, global ref, live connection). None
exists in this codebase (see the vector table in the main report).
## Contributing (non-defect) observations
- **Extraction-poll churn** (`ProfilePage.tsx:315-324`): interval recreated every 4s while a run is
active because `extractionRuns` is in the deps and mutates each poll. Harmless; optionally stabilise
(see improvements doc).
+2
View File
@@ -28,6 +28,7 @@ import JobTable from "./components/JobTable";
import type { JobTableColumns } from "./components/JobTable"; import type { JobTableColumns } from "./components/JobTable";
import { I18nProvider, useI18n } from "./i18n/I18nProvider"; import { I18nProvider, useI18n } from "./i18n/I18nProvider";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import LandingPage from "./pages/LandingPage";
import ForgotPasswordPage from "./pages/ForgotPasswordPage"; import ForgotPasswordPage from "./pages/ForgotPasswordPage";
import ResetPasswordPage from "./pages/ResetPasswordPage"; import ResetPasswordPage from "./pages/ResetPasswordPage";
import RouteErrorPage from "./pages/RouteErrorPage"; import RouteErrorPage from "./pages/RouteErrorPage";
@@ -344,6 +345,7 @@ export default function App() {
}); });
const router = useMemo(() => createBrowserRouter([ const router = useMemo(() => createBrowserRouter([
{ path: "/", element: <LandingPage />, errorElement: <RouteErrorPage /> },
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> }, { path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> }, { path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> }, { path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
+7 -1
View File
@@ -82,8 +82,14 @@ export function setAuthUserKey(value: string | null | undefined, emit = true) {
} }
export function clearAuthClientState(emit = true) { export function clearAuthClientState(emit = true) {
// Only emit "auth-changed" when this call actually transitions from
// "signed in" to "signed out". The response interceptor calls this on every
// 401; without this guard each 401 re-dispatches "auth-changed", which
// re-fetches /auth/me, which 401s again — an infinite request loop whenever
// the user is logged out (login page, expired session).
const had = safeGet(window.localStorage, AUTH_USER_KEY) != null;
safeRemove(window.localStorage, AUTH_USER_KEY); safeRemove(window.localStorage, AUTH_USER_KEY);
if (emit) emitAuthChanged(); if (emit && had) emitAuthChanged();
} }
export function getCsrfToken(): string | null { export function getCsrfToken(): string | null {
@@ -46,7 +46,7 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch
return <Box sx={{ mt: 2 }}>{children}</Box>; return <Box sx={{ mt: 2 }}>{children}</Box>;
} }
const ACCENTS = ["#15803d", "#16a34a", "#22c55e", "#0f766e", "#2563eb", "#65a30d", "#8b5cf6", "#f97316"]; const ACCENTS = ["#6366f1", "#22d3ee", "#2563eb", "#8b5cf6", "#15803d", "#16a34a", "#0f766e", "#f97316"];
const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs"; const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs";
type NotificationPrefs = { type NotificationPrefs = {
+237
View File
@@ -0,0 +1,237 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Container, Stack, Typography } from "@mui/material";
import { alpha } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import DashboardIcon from "@mui/icons-material/SpaceDashboardOutlined";
import AlarmIcon from "@mui/icons-material/NotificationsActiveOutlined";
import MatchIcon from "@mui/icons-material/FactCheckOutlined";
import MailIcon from "@mui/icons-material/MarkEmailReadOutlined";
import AttachIcon from "@mui/icons-material/DescriptionOutlined";
import InsightsIcon from "@mui/icons-material/InsightsOutlined";
import { api } from "../api";
const BRAND_DARK = "#0b1020";
const BRAND_PANEL = "#111a33";
const FEATURES: { icon: React.ReactNode; title: string; body: string }[] = [
{ icon: <DashboardIcon />, title: "Centralized pipeline", body: "Track every application across Applied, Waiting, Interview, Offer, Rejected and Ghosted — drag to update." },
{ icon: <AlarmIcon />, title: "Smart follow-ups", body: "Reminders surface what needs attention next, with a grounded draft ready to review and send." },
{ icon: <MatchIcon />, title: "Honest CV match", body: "A deterministic keyword-coverage score with matched vs missing skills — not an opaque black box." },
{ icon: <MailIcon />, title: "Email correspondence", body: "Link Gmail threads to a job; new replies appear automatically without re-importing." },
{ icon: <AttachIcon />, title: "Attachments & docs", body: "Keep resumes, cover letters and portfolios versioned per application, right where you need them." },
{ icon: <InsightsIcon />, title: "Dashboard & insights", body: "Response rates, funnel, time-in-stage and skill demand across your whole search." },
];
const STEPS: { n: number; title: string; body: string }[] = [
{ n: 1, title: "Import", body: "Paste a job URL or use the bookmarklet — we parse the role into structured fields." },
{ n: 2, title: "Match", body: "See how your CV covers the role: matched keywords and the gaps to close." },
{ n: 3, title: "Tailor", body: "AI drafts a tailored CV and cover letter — you review every word before it goes out." },
{ n: 4, title: "Track", body: "Move it through the pipeline; documents, notes and history stay attached." },
{ n: 5, title: "Follow up", body: "Linked email threads and reminders keep momentum with grounded replies." },
{ n: 6, title: "Analyze", body: "See what's working — response rate, funnel and time-in-stage — and focus your effort." },
];
const PRICING: { name: string; price: string; cadence: string; highlight: boolean; features: string[]; cta: string }[] = [
{ name: "Free", price: "£0", cadence: "forever", highlight: false, cta: "Get started", features: ["Unlimited job tracking & pipeline", "One-click capture (bookmarklet + PWA)", "Deterministic CV↔job match score", "3 AI CV tailors / month"] },
{ name: "Pro", price: "£9", cadence: "/ month · billed monthly or yearly", highlight: true, cta: "Start Pro", features: ["Everything in Free", "Unlimited AI CV & cover-letter tailoring", "CV versions + factuality guardrail", "Gmail correspondence CRM", "Analytics drill-downs"] },
{ name: "Bring your own key", price: "£3", cadence: "/ month + your AI key", highlight: false, cta: "Get started", features: ["Everything in Pro", "Use your own Gemini / Groq key", "Unlimited AI at provider cost", "Privacy-first & self-host friendly"] },
];
export default function LandingPage() {
const navigate = useNavigate();
const [checking, setChecking] = useState(true);
// If the visitor already has a session, send them straight into the app.
useEffect(() => {
let active = true;
api
.get("/auth/me")
.then(() => { if (active) navigate("/jobs", { replace: true }); })
.catch(() => { if (active) setChecking(false); });
return () => { active = false; };
}, [navigate]);
if (checking) {
return (
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", bgcolor: BRAND_DARK }}>
<Typography sx={{ color: "#94a3b8" }}>Loading</Typography>
</Box>
);
}
const gradientText = {
background: "linear-gradient(90deg,#6366f1,#22d3ee)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
} as const;
return (
<Box sx={{ bgcolor: "background.default" }}>
{/* Top bar */}
<Box sx={{ position: "sticky", top: 0, zIndex: 10, bgcolor: alpha(BRAND_DARK, 0.85), backdropFilter: "blur(8px)", borderBottom: `1px solid ${alpha("#ffffff", 0.08)}` }}>
<Container maxWidth="lg">
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ height: 64 }}>
<Stack direction="row" alignItems="center" spacing={1.25}>
<Box sx={{ width: 30, height: 30, borderRadius: "8px", background: "linear-gradient(135deg,#6366f1,#22d3ee)", display: "grid", placeItems: "center", color: BRAND_DARK, fontWeight: 900 }}></Box>
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>JobTrack</Typography>
</Stack>
<Button variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
Sign in
</Button>
</Stack>
</Container>
</Box>
{/* Hero */}
<Box sx={{ background: `radial-gradient(1200px 500px at 80% -10%, ${alpha("#6366f1", 0.35)}, transparent), linear-gradient(180deg, ${BRAND_DARK}, ${BRAND_PANEL})`, color: "#fff", py: { xs: 8, md: 12 } }}>
<Container maxWidth="lg">
<Box sx={{ maxWidth: 760 }}>
<Box sx={{ display: "inline-block", px: 1.5, py: 0.5, borderRadius: 999, bgcolor: alpha("#ffffff", 0.08), color: "#a5b4fc", fontSize: 13, fontWeight: 600, letterSpacing: 0.5, mb: 3 }}>
AI-ASSISTED JOB SEARCH WORKSPACE
</Box>
<Typography component="h1" sx={{ fontWeight: 800, fontSize: { xs: 40, md: 60 }, lineHeight: 1.05, mb: 2 }}>
Run your job search without losing <Box component="span" sx={gradientText}>the thread</Box>.
</Typography>
<Typography sx={{ color: "#94a3b8", fontSize: { xs: 17, md: 20 }, mb: 4 }}>
Import a role, tailor your CV, track every application, and keep recruiter correspondence tied to the
right job all in one focused workspace. Assistive, never autonomous: you approve every draft.
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
Get started
</Button>
<Button size="large" variant="outlined" href="#features" sx={{ color: "#e2e8f0", borderColor: alpha("#ffffff", 0.25), px: 3, py: 1.25 }}>
See features
</Button>
</Stack>
<Typography sx={{ color: "#64748b", fontSize: 14, mt: 3 }}>
React · TypeScript · ASP.NET Core · EF Core · FastAPI AI · Gmail
</Typography>
</Box>
</Container>
</Box>
{/* Features */}
<Container id="features" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
<Box sx={{ textAlign: "center", mb: 6 }}>
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>WHAT IT DOES</Typography>
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>One workspace for the whole search</Typography>
<Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}>
Everything from a single import to the final offer no more spreadsheets and scattered inboxes.
</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}>
{FEATURES.map((f) => (
<Box key={f.title} sx={{ p: 3, borderRadius: 3, border: "1px solid", borderColor: "divider", bgcolor: "background.paper", transition: "box-shadow .2s, transform .2s", "&:hover": { boxShadow: 6, transform: "translateY(-2px)" } }}>
<Box sx={{ width: 48, height: 48, borderRadius: 2.5, display: "grid", placeItems: "center", bgcolor: alpha("#6366f1", 0.12), color: "primary.main", mb: 2 }}>{f.icon}</Box>
<Typography sx={{ fontWeight: 700, fontSize: 19, mb: 0.75 }}>{f.title}</Typography>
<Typography sx={{ color: "text.secondary", fontSize: 15 }}>{f.body}</Typography>
</Box>
))}
</Box>
</Container>
{/* How it works */}
<Box sx={{ background: `linear-gradient(180deg, ${BRAND_DARK}, ${BRAND_PANEL})`, color: "#fff", py: { xs: 7, md: 10 } }}>
<Container maxWidth="lg">
<Box sx={{ textAlign: "center", mb: 6 }}>
<Typography sx={{ color: "#a5b4fc", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>HOW IT WORKS</Typography>
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>From a link to an offer</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}>
{STEPS.map((s) => (
<Box key={s.n} sx={{ p: 3, borderRadius: 3, border: `1px solid ${alpha("#ffffff", 0.1)}`, bgcolor: alpha("#ffffff", 0.03) }}>
<Box sx={{ width: 40, height: 40, borderRadius: 999, display: "grid", placeItems: "center", background: "linear-gradient(135deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 900, mb: 1.5 }}>{s.n}</Box>
<Typography sx={{ fontWeight: 700, fontSize: 18, mb: 0.5 }}>{s.title}</Typography>
<Typography sx={{ color: "#94a3b8", fontSize: 15 }}>{s.body}</Typography>
</Box>
))}
</Box>
</Container>
</Box>
{/* Pricing */}
<Container id="pricing" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
<Box sx={{ textAlign: "center", mb: 6 }}>
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>PRICING</Typography>
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>Honest, simple pricing</Typography>
<Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}>
Billed monthly or yearly never by the week. Cancel anytime.
</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 3, alignItems: "start" }}>
{PRICING.map((tier) => (
<Box
key={tier.name}
sx={{
p: 3.5,
borderRadius: 3,
position: "relative",
bgcolor: "background.paper",
border: "2px solid",
borderColor: tier.highlight ? "primary.main" : "divider",
boxShadow: tier.highlight ? 8 : 0,
}}
>
{tier.highlight && (
<Box sx={{ position: "absolute", top: -13, left: 24, px: 1.5, py: 0.5, borderRadius: 999, background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontSize: 12, fontWeight: 800 }}>
Most popular
</Box>
)}
<Typography sx={{ fontWeight: 700, fontSize: 18 }}>{tier.name}</Typography>
<Stack direction="row" alignItems="baseline" spacing={0.75} sx={{ my: 1.5 }}>
<Typography sx={{ fontWeight: 900, fontSize: 40, lineHeight: 1 }}>{tier.price}</Typography>
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>{tier.cadence}</Typography>
</Stack>
<Stack spacing={1.25} sx={{ my: 2.5 }}>
{tier.features.map((f) => (
<Stack key={f} direction="row" spacing={1.25} alignItems="flex-start">
<Box sx={{ color: "success.main", fontWeight: 900, lineHeight: 1.4 }}></Box>
<Typography sx={{ fontSize: 15, color: "text.secondary" }}>{f}</Typography>
</Stack>
))}
</Stack>
<Button
fullWidth
variant={tier.highlight ? "contained" : "outlined"}
onClick={() => navigate("/login")}
sx={tier.highlight ? { background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800 } : { fontWeight: 700 }}
>
{tier.cta}
</Button>
</Box>
))}
</Box>
<Typography sx={{ textAlign: "center", color: "text.secondary", fontSize: 13, mt: 3 }}>
Prices indicative assistive, never autonomous: you always review and send. No auto-apply spam.
</Typography>
</Container>
{/* CTA */}
<Container maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
<Box sx={{ borderRadius: 4, p: { xs: 4, md: 6 }, background: "linear-gradient(120deg,#0f172a,#1e293b)", color: "#fff", display: "flex", flexDirection: { xs: "column", md: "row" }, alignItems: { md: "center" }, justifyContent: "space-between", gap: 3 }}>
<Box>
<Typography sx={{ fontWeight: 800, fontSize: { xs: 24, md: 30 }, mb: 1 }}>Ready to organize your search?</Typography>
<Typography sx={{ color: "#94a3b8", fontSize: 17 }}>Sign in to start tracking applications, tailoring CVs, and following up with intent.</Typography>
</Box>
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
Sign in
</Button>
</Box>
</Container>
{/* Footer */}
<Box sx={{ borderTop: "1px solid", borderColor: "divider", py: 4 }}>
<Container maxWidth="lg">
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="center" spacing={1}>
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>© {new Date().getFullYear()} JobTrack a focused workspace for the modern job search.</Typography>
<Button variant="text" onClick={() => navigate("/login")} sx={{ fontWeight: 700 }}>Sign in</Button>
</Stack>
</Container>
</Box>
</Box>
);
}
+14 -2
View File
@@ -267,15 +267,27 @@ export default function ProfilePage() {
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
// Keep a ref to the latest carousel so the unmount cleanup can revoke the
// outstanding preview object URLs without re-running on every change.
const pdfCarouselRef = useRef<PdfCarouselItem[]>([]);
useEffect(() => { useEffect(() => {
pdfCarouselRef.current = pdfCarousel;
}, [pdfCarousel]);
useEffect(() => {
// Revoke any remaining preview object URLs only on unmount. Per-change
// revocation is already handled explicitly in savePdfToCarousel (replace) and
// resetPdfCarousel (clear); doing it here on every pdfCarousel change revoked
// URLs that were still referenced by other items in the deck, breaking their
// previews.
return () => { return () => {
pdfCarousel.forEach((item) => { pdfCarouselRef.current.forEach((item) => {
if (item.pdfUrl) { if (item.pdfUrl) {
window.URL.revokeObjectURL(item.pdfUrl); window.URL.revokeObjectURL(item.pdfUrl);
} }
}); });
}; };
}, [pdfCarousel]); }, []);
const loadProfile = useCallback(async () => { const loadProfile = useCallback(async () => {
setLoading(true); setLoading(true);
+8 -6
View File
@@ -24,7 +24,7 @@ function buildLightPalette(accentColor: string): PaletteLike {
const disabledBackground = "#E4E1E6"; const disabledBackground = "#E4E1E6";
return { return {
primary: buildPrimary(accentColor || "#15803D"), primary: buildPrimary(accentColor || "#6366F1"),
secondary: { secondary: {
lighter: "#E0E0FF", lighter: "#E0E0FF",
light: "#C3C4E4", light: "#C3C4E4",
@@ -78,9 +78,11 @@ function buildLightPalette(accentColor: string): PaletteLike {
disabled, disabled,
}, },
divider, divider,
background: { default: background, paper: background }, // Soft grey app background with white paper gives the layered dashboard look
// from the product mockups; cards/inputs (paper) sit above it.
background: { default: "#F4F6FB", paper: background },
action: { action: {
hover: alpha(accentColor || "#15803D", 0.05), hover: alpha(accentColor || "#6366F1", 0.05),
disabled: alpha(disabled, 0.6), disabled: alpha(disabled, 0.6),
disabledBackground: alpha(disabledBackground, 0.9), disabledBackground: alpha(disabledBackground, 0.9),
}, },
@@ -99,7 +101,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
const disabledBackground = alpha("#FFFFFF", 0.08); const disabledBackground = alpha("#FFFFFF", 0.08);
return { return {
primary: buildPrimary(accentColor || "#15803D"), primary: buildPrimary(accentColor || "#6366F1"),
secondary: { secondary: {
lighter: alpha(secondaryMain, 0.22), lighter: alpha(secondaryMain, 0.22),
light: alpha(secondaryMain, 0.14), light: alpha(secondaryMain, 0.14),
@@ -155,7 +157,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
divider, divider,
background: { default: bg, paper }, background: { default: bg, paper },
action: { action: {
hover: alpha(accentColor || "#15803D", 0.16), hover: alpha(accentColor || "#6366F1", 0.16),
disabled: alpha("#FFFFFF", 0.5), disabled: alpha("#FFFFFF", 0.5),
disabledBackground, disabledBackground,
}, },
@@ -216,7 +218,7 @@ export const getTheme = (_mode: "light" | "dark", accentColor: string) => {
light: { palette: lightPalette, customShadows: buildCustomShadows(lightPalette) }, light: { palette: lightPalette, customShadows: buildCustomShadows(lightPalette) },
dark: { palette: darkPalette, customShadows: buildCustomShadows(darkPalette) }, dark: { palette: darkPalette, customShadows: buildCustomShadows(darkPalette) },
}, },
shape: { borderRadius: 8 }, shape: { borderRadius: 10 },
typography: buildTypography() as any, typography: buildTypography() as any,
} as any) as any; } as any) as any;
+1 -1
View File
@@ -23,7 +23,7 @@ export function setThemeModePref(v: ThemeModePref) {
export function getAccentColor(): string { export function getAccentColor(): string {
const raw = window.localStorage.getItem(k("accentColor")); const raw = window.localStorage.getItem(k("accentColor"));
if (raw && /^#[0-9a-fA-F]{6}$/.test(raw)) return raw; if (raw && /^#[0-9a-fA-F]{6}$/.test(raw)) return raw;
return "#15803d"; return "#6366f1";
} }
export function setAccentColor(v: string) { export function setAccentColor(v: string) {
+105 -48
View File
@@ -26,6 +26,18 @@ OCR_LANGUAGES = "eng"
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/") OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "")
# AI provider router. Structured /cv/* calls (the heavy ones) dispatch through the
# active provider so production can offload a weak local GPU to a cloud provider.
# Default stays "ollama" so the service works keyless/local. /summarize stays local
# (distilbart) regardless of this setting.
AI_PROVIDER = (os.getenv("AI_PROVIDER", "ollama").strip().lower() or "ollama")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash").strip()
GEMINI_BASE_URL = os.getenv("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com").rstrip("/")
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile").strip()
GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").rstrip("/")
SKIP_MODEL_LOAD = os.getenv("AI_SERVICE_SKIP_MODEL_LOAD", "") == "1" SKIP_MODEL_LOAD = os.getenv("AI_SERVICE_SKIP_MODEL_LOAD", "") == "1"
EAGER_MODEL_LOAD = os.getenv("AI_SERVICE_EAGER_MODEL_LOAD", "") == "1" EAGER_MODEL_LOAD = os.getenv("AI_SERVICE_EAGER_MODEL_LOAD", "") == "1"
@@ -174,6 +186,8 @@ async def health():
"model_disabled": MODEL_DISABLED, "model_disabled": MODEL_DISABLED,
"summarize_available": MODEL_LOADED and not MODEL_DISABLED, "summarize_available": MODEL_LOADED and not MODEL_DISABLED,
"model_load_error": MODEL_LOAD_ERROR, "model_load_error": MODEL_LOAD_ERROR,
"ai_provider": AI_PROVIDER,
"ai_provider_configured": _provider_configured(),
**_ollama_status(), **_ollama_status(),
} }
@@ -390,37 +404,106 @@ def _model_summarize(text: str, max_length: int, min_length: int) -> str:
return tokenizer.decode(outputs[0], skip_special_tokens=True).strip() return tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
def _ollama_generate_json(prompt: str): _PROVIDER_DISPLAY = {"ollama": "Ollama", "gemini": "Gemini", "groq": "Groq"}
def _provider_display(provider: str) -> str:
return _PROVIDER_DISPLAY.get(provider, provider or "AI provider")
def _provider_configured() -> bool:
if AI_PROVIDER == "gemini":
return bool(GEMINI_API_KEY)
if AI_PROVIDER == "groq":
return bool(GROQ_API_KEY)
return bool(OLLAMA_MODEL)
def _http_post_json(url: str, payload: dict, headers: dict, timeout: int) -> dict:
data = json.dumps(payload).encode("utf-8")
req = urllib_request.Request(
url,
data=data,
headers={"Content-Type": "application/json", **headers},
method="POST",
)
with urllib_request.urlopen(req, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def _ollama_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str:
if not OLLAMA_MODEL: if not OLLAMA_MODEL:
raise HTTPException(status_code=503, detail="OLLAMA_MODEL is not configured.") raise HTTPException(status_code=503, detail="OLLAMA_MODEL is not configured.")
payload = {
payload = json.dumps({
"model": OLLAMA_MODEL, "model": OLLAMA_MODEL,
"prompt": prompt, "prompt": prompt,
"stream": False, "stream": False,
"format": "json", "options": {"temperature": temperature},
"options": {"temperature": 0.1} }
}).encode("utf-8") if json_mode:
payload["format"] = "json"
body = _http_post_json(f"{OLLAMA_BASE_URL}/api/generate", payload, {}, timeout)
return (body.get("response") or "").strip()
req = urllib_request.Request(
f"{OLLAMA_BASE_URL}/api/generate",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
def _gemini_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str:
if not GEMINI_API_KEY:
raise HTTPException(status_code=503, detail="GEMINI_API_KEY is not configured.")
generation_config = {"temperature": temperature}
if json_mode:
generation_config["responseMimeType"] = "application/json"
payload = {
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
"generationConfig": generation_config,
}
# Pass the key via header (not the URL query string, which can leak into logs).
url = f"{GEMINI_BASE_URL}/v1beta/models/{GEMINI_MODEL}:generateContent"
body = _http_post_json(url, payload, {"x-goog-api-key": GEMINI_API_KEY}, timeout)
candidates = body.get("candidates") or []
if not candidates:
return ""
parts = (candidates[0].get("content") or {}).get("parts") or []
return "".join(part.get("text", "") for part in parts).strip()
def _groq_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str:
if not GROQ_API_KEY:
raise HTTPException(status_code=503, detail="GROQ_API_KEY is not configured.")
payload = {
"model": GROQ_MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
url = f"{GROQ_BASE_URL}/chat/completions"
body = _http_post_json(url, payload, {"Authorization": f"Bearer {GROQ_API_KEY}"}, timeout)
choices = body.get("choices") or []
if not choices:
return ""
return ((choices[0].get("message") or {}).get("content") or "").strip()
def _provider_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str:
provider = AI_PROVIDER
try: try:
with urllib_request.urlopen(req, timeout=120) as response: if provider == "gemini":
body = json.loads(response.read().decode("utf-8")) return _gemini_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout)
if provider == "groq":
return _groq_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout)
return _ollama_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout)
except HTTPException:
raise
except HTTPError as ex: except HTTPError as ex:
raise HTTPException(status_code=502, detail=f"Ollama request failed with {ex.code}.") raise HTTPException(status_code=502, detail=f"{_provider_display(provider)} request failed with {ex.code}.")
except URLError as ex: except URLError as ex:
raise HTTPException(status_code=503, detail=f"Ollama is unreachable: {ex.reason}.") raise HTTPException(status_code=503, detail=f"{_provider_display(provider)} is unreachable: {ex.reason}.")
raw = (body.get("response") or "").strip()
def _ollama_generate_json(prompt: str):
raw = _provider_generate(prompt, json_mode=True, temperature=0.1, timeout=120)
if not raw: if not raw:
raise HTTPException(status_code=502, detail="Ollama returned an empty response.") raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} returned an empty response.")
try: try:
return json.loads(raw) return json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -428,39 +511,13 @@ def _ollama_generate_json(prompt: str):
end = raw.rfind("}") end = raw.rfind("}")
if start >= 0 and end > start: if start >= 0 and end > start:
return json.loads(raw[start:end + 1]) return json.loads(raw[start:end + 1])
raise HTTPException(status_code=502, detail="Ollama did not return valid JSON.") raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} did not return valid JSON.")
def _ollama_generate_text(prompt: str) -> str: def _ollama_generate_text(prompt: str) -> str:
if not OLLAMA_MODEL: raw = _provider_generate(prompt, json_mode=False, temperature=0.2, timeout=180)
raise HTTPException(status_code=503, detail="OLLAMA_MODEL is not configured.")
payload = json.dumps({
"model": OLLAMA_MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.2}
}).encode("utf-8")
req = urllib_request.Request(
f"{OLLAMA_BASE_URL}/api/generate",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib_request.urlopen(req, timeout=180) as response:
body = json.loads(response.read().decode("utf-8"))
except HTTPError as ex:
raise HTTPException(status_code=502, detail=f"Ollama request failed with {ex.code}.")
except URLError as ex:
raise HTTPException(status_code=503, detail=f"Ollama is unreachable: {ex.reason}.")
raw = (body.get("response") or "").strip()
if not raw: if not raw:
raise HTTPException(status_code=502, detail="Ollama returned an empty rewrite.") raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} returned an empty rewrite.")
return raw return raw
+104
View File
@@ -1,4 +1,5 @@
import importlib import importlib
import json
import sys import sys
from pathlib import Path from pathlib import Path
@@ -141,3 +142,106 @@ def test_classify_block_defaults_missing_section_to_other(monkeypatch):
assert payload["bullets"] == [] assert payload["bullets"] == []
assert payload["summary"] == [] assert payload["summary"] == []
assert payload["skills"] == [] assert payload["skills"] == []
# --- AI provider router -------------------------------------------------------
class _FakeResponse:
def __init__(self, payload):
self._data = json.dumps(payload).encode("utf-8")
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def _install_fake_urlopen(monkeypatch, module, response_payload, captured):
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
captured["body"] = json.loads(req.data.decode("utf-8"))
return _FakeResponse(response_payload)
monkeypatch.setattr(module.urllib_request, "urlopen", fake_urlopen)
def test_provider_defaults_to_ollama_and_is_unchanged(monkeypatch):
monkeypatch.delenv("AI_PROVIDER", raising=False)
monkeypatch.setenv("OLLAMA_BASE_URL", "http://ollama-host:11434")
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b")
assert module.AI_PROVIDER == "ollama"
captured = {}
_install_fake_urlopen(monkeypatch, module, {"response": '{"score": 7}'}, captured)
assert module._ollama_generate_json("hi") == {"score": 7}
assert captured["url"] == "http://ollama-host:11434/api/generate"
assert captured["body"]["model"] == "qwen2.5:7b"
assert captured["body"]["format"] == "json"
assert captured["body"]["options"]["temperature"] == 0.1
def test_provider_gemini_dispatch(monkeypatch):
monkeypatch.setenv("AI_PROVIDER", "gemini")
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
monkeypatch.setenv("GEMINI_MODEL", "gemini-2.0-flash")
module = load_app_module(monkeypatch)
captured = {}
payload = {"candidates": [{"content": {"parts": [{"text": '{"score": 9}'}]}}]}
_install_fake_urlopen(monkeypatch, module, payload, captured)
assert module._ollama_generate_json("hi") == {"score": 9}
assert "generativelanguage" in captured["url"]
assert "gemini-2.0-flash:generateContent" in captured["url"]
assert "key=" not in captured["url"] # key must not be in the URL
assert captured["headers"].get("x-goog-api-key") == "test-key"
assert captured["body"]["generationConfig"]["responseMimeType"] == "application/json"
def test_provider_groq_dispatch(monkeypatch):
monkeypatch.setenv("AI_PROVIDER", "groq")
monkeypatch.setenv("GROQ_API_KEY", "test-key")
module = load_app_module(monkeypatch)
captured = {}
payload = {"choices": [{"message": {"content": "rewritten CV text"}}]}
_install_fake_urlopen(monkeypatch, module, payload, captured)
assert module._ollama_generate_text("rewrite this") == "rewritten CV text"
assert captured["url"].endswith("/chat/completions")
assert captured["headers"].get("authorization") == "Bearer test-key"
assert captured["body"]["messages"][0]["content"] == "rewrite this"
def test_provider_missing_cloud_key_raises_503(monkeypatch):
monkeypatch.setenv("AI_PROVIDER", "gemini")
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
module = load_app_module(monkeypatch)
from fastapi import HTTPException
try:
module._ollama_generate_json("hi")
except HTTPException as ex:
assert ex.status_code == 503
assert "GEMINI_API_KEY" in ex.detail
else:
raise AssertionError("expected HTTPException for missing GEMINI_API_KEY")
def test_health_reports_active_provider(monkeypatch):
monkeypatch.setenv("AI_PROVIDER", "gemini")
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
module = load_app_module(monkeypatch)
client = TestClient(module.app)
payload = client.get("/health").json()
assert payload["ai_provider"] == "gemini"
assert payload["ai_provider_configured"] is True