Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5e2c65709 | |||
| 2989a6fa2c | |||
| 824251d328 | |||
| b8ec268736 | |||
| 6cb593ab5c |
@@ -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,8 +23,9 @@ 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;
|
||||||
|
|
||||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
|
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_summarizer = summarizer;
|
_summarizer = summarizer;
|
||||||
@@ -33,6 +34,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
||||||
@@ -1736,46 +1738,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")]
|
||||||
@@ -1972,19 +1937,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 AnalyticsOverviewDto(
|
|
||||||
List<FunnelStagePoint> Funnel,
|
|
||||||
List<ResponseRatePoint> ResponseRateBySource,
|
|
||||||
List<CompanyActivityPoint> TopCompanies,
|
|
||||||
double? MedianDaysToFirstResponse,
|
|
||||||
int TotalResponses,
|
|
||||||
int TotalActive
|
|
||||||
);
|
|
||||||
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);
|
||||||
@@ -2667,75 +2621,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);
|
|
||||||
|
|
||||||
var funnelMap = new Dictionary<string, int>
|
|
||||||
{
|
|
||||||
["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)),
|
|
||||||
["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)),
|
|
||||||
["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)),
|
|
||||||
["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)),
|
|
||||||
["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)),
|
|
||||||
};
|
|
||||||
|
|
||||||
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("tag-trends")]
|
[HttpGet("tag-trends")]
|
||||||
public async Task<ActionResult<TagTrendResponse>> GetTagTrends(
|
public async Task<ActionResult<TagTrendResponse>> GetTagTrends(
|
||||||
|
|||||||
@@ -153,6 +153,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<ICvAiClassifier, CvAiClassifier>();
|
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||||
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
|
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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.Status,
|
||||||
|
j.ResponseReceived,
|
||||||
|
j.ResponseDate,
|
||||||
|
j.DateApplied,
|
||||||
|
j.CompanyId,
|
||||||
|
CompanyName = j.Company.Name,
|
||||||
|
CompanySource = j.Company.Source
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var funnelMap = new Dictionary<string, int>
|
||||||
|
{
|
||||||
|
["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)),
|
||||||
|
["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)),
|
||||||
|
["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)),
|
||||||
|
["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)),
|
||||||
|
["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)),
|
||||||
|
};
|
||||||
|
|
||||||
|
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -499,6 +499,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);
|
||||||
}
|
}
|
||||||
@@ -819,6 +829,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,30 @@
|
|||||||
|
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 AnalyticsOverviewDto(
|
||||||
|
List<FunnelStagePoint> Funnel,
|
||||||
|
List<ResponseRatePoint> ResponseRateBySource,
|
||||||
|
List<CompanyActivityPoint> TopCompanies,
|
||||||
|
double? MedianDaysToFirstResponse,
|
||||||
|
int TotalResponses,
|
||||||
|
int TotalActive
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,6 +74,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- 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"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -45,7 +45,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 = {
|
||||||
|
|||||||
@@ -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