perf(analytics): project minimal columns in GetStats/GetAnalyticsOverview #3
@@ -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=
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 1–2 — 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. 15–20s) 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.
|
||||||
@@ -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).
|
||||||
@@ -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 /> },
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user