Compare commits
10 Commits
811963749e
...
657cb95a48
| Author | SHA1 | Date | |
|---|---|---|---|
| 657cb95a48 | |||
| eea327e1f6 | |||
| 54abc9f546 | |||
| 591c9b8a64 | |||
| 534534b333 | |||
| fcccecefa3 | |||
| 48cd83b442 | |||
| b52371ea79 | |||
| cc97a6b6c5 | |||
| 5f2f0a881a |
@@ -59,6 +59,7 @@ namespace JobTrackerApi.Data
|
|||||||
.HasIndex(c => c.OwnerUserId);
|
.HasIndex(c => c.OwnerUserId);
|
||||||
|
|
||||||
modelBuilder.Entity<Correspondence>()
|
modelBuilder.Entity<Correspondence>()
|
||||||
|
.HasQueryFilter(c => CurrentUserId != null && c.JobApplication.OwnerUserId == CurrentUserId)
|
||||||
.HasOne(c => c.JobApplication)
|
.HasOne(c => c.JobApplication)
|
||||||
.WithMany(j => j.Messages)
|
.WithMany(j => j.Messages)
|
||||||
.HasForeignKey(c => c.JobApplicationId)
|
.HasForeignKey(c => c.JobApplicationId)
|
||||||
|
|||||||
@@ -556,6 +556,129 @@ public sealed class ProfileCvControllerTests
|
|||||||
Assert.Equal("Warwickshire College, UK", structured.Education[0].Location);
|
Assert.Equal("Warwickshire College, UK", structured.Education[0].Location);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rewrite_section_returns_ai_service_unavailable_detail_when_ai_health_is_unhealthy()
|
||||||
|
{
|
||||||
|
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." };
|
||||||
|
var userManager = CreateUserManager();
|
||||||
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
var aiService = new Mock<ISummarizerService>();
|
||||||
|
aiService
|
||||||
|
.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), 1800, 400))
|
||||||
|
.ReturnsAsync(string.Empty);
|
||||||
|
aiService
|
||||||
|
.Setup(x => x.GetMetricsAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new AiServiceMetrics(
|
||||||
|
Healthy: false,
|
||||||
|
Model: "distilbart",
|
||||||
|
Device: "cpu",
|
||||||
|
GpuAvailable: false,
|
||||||
|
GpuName: null,
|
||||||
|
OcrAvailable: true,
|
||||||
|
OcrLanguages: "eng",
|
||||||
|
OllamaConfigured: true,
|
||||||
|
OllamaReachable: true,
|
||||||
|
OllamaModel: "qwen2.5:7b",
|
||||||
|
OllamaModelAvailable: true,
|
||||||
|
OllamaVersion: "0.6.0",
|
||||||
|
OllamaInstalledModels: new List<string> { "qwen2.5:7b" },
|
||||||
|
OllamaLoadedModels: new List<string>(),
|
||||||
|
OllamaLoadedCount: 0,
|
||||||
|
HealthLatencyMs: 21,
|
||||||
|
ProbeLatencyMs: null,
|
||||||
|
LastProbeAt: null,
|
||||||
|
LastProbeSuccessAt: null,
|
||||||
|
LastProbeFailureAt: null,
|
||||||
|
ProbeFailures: 1,
|
||||||
|
Requests: 1,
|
||||||
|
CacheHits: 0,
|
||||||
|
CacheMisses: 1,
|
||||||
|
Failures: 1,
|
||||||
|
AverageLatencyMs: 21,
|
||||||
|
OcrRequests: 0,
|
||||||
|
OcrFailures: 0,
|
||||||
|
AverageOcrLatencyMs: null,
|
||||||
|
LastOcrSuccessAt: null,
|
||||||
|
LastOcrFailureAt: null,
|
||||||
|
LastSuccessAt: null,
|
||||||
|
LastFailureAt: DateTimeOffset.UtcNow,
|
||||||
|
LastError: "Model loading is disabled by AI_SERVICE_SKIP_MODEL_LOAD."));
|
||||||
|
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||||
|
|
||||||
|
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest());
|
||||||
|
|
||||||
|
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||||
|
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
|
||||||
|
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
|
||||||
|
Assert.Equal("ai-service-unavailable", payload.Code);
|
||||||
|
Assert.Contains("could not rewrite", payload.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("unavailable", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("AI_SERVICE_SKIP_MODEL_LOAD", payload.LastAiError ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rewrite_section_returns_rewrite_empty_detail_when_ai_health_is_healthy()
|
||||||
|
{
|
||||||
|
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." };
|
||||||
|
var userManager = CreateUserManager();
|
||||||
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
var aiService = new Mock<ISummarizerService>();
|
||||||
|
aiService
|
||||||
|
.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), 1800, 400))
|
||||||
|
.ReturnsAsync(string.Empty);
|
||||||
|
aiService
|
||||||
|
.Setup(x => x.GetMetricsAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new AiServiceMetrics(
|
||||||
|
Healthy: true,
|
||||||
|
Model: "distilbart",
|
||||||
|
Device: "cpu",
|
||||||
|
GpuAvailable: false,
|
||||||
|
GpuName: null,
|
||||||
|
OcrAvailable: true,
|
||||||
|
OcrLanguages: "eng",
|
||||||
|
OllamaConfigured: true,
|
||||||
|
OllamaReachable: true,
|
||||||
|
OllamaModel: "qwen2.5:7b",
|
||||||
|
OllamaModelAvailable: true,
|
||||||
|
OllamaVersion: "0.6.0",
|
||||||
|
OllamaInstalledModels: new List<string> { "qwen2.5:7b" },
|
||||||
|
OllamaLoadedModels: new List<string>(),
|
||||||
|
OllamaLoadedCount: 0,
|
||||||
|
HealthLatencyMs: 21,
|
||||||
|
ProbeLatencyMs: null,
|
||||||
|
LastProbeAt: null,
|
||||||
|
LastProbeSuccessAt: null,
|
||||||
|
LastProbeFailureAt: null,
|
||||||
|
ProbeFailures: 0,
|
||||||
|
Requests: 1,
|
||||||
|
CacheHits: 0,
|
||||||
|
CacheMisses: 1,
|
||||||
|
Failures: 0,
|
||||||
|
AverageLatencyMs: 21,
|
||||||
|
OcrRequests: 0,
|
||||||
|
OcrFailures: 0,
|
||||||
|
AverageOcrLatencyMs: null,
|
||||||
|
LastOcrSuccessAt: null,
|
||||||
|
LastOcrFailureAt: null,
|
||||||
|
LastSuccessAt: DateTimeOffset.UtcNow,
|
||||||
|
LastFailureAt: null,
|
||||||
|
LastError: null));
|
||||||
|
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||||
|
|
||||||
|
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest());
|
||||||
|
|
||||||
|
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||||
|
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
|
||||||
|
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
|
||||||
|
Assert.Equal("rewrite-empty", payload.Code);
|
||||||
|
Assert.Contains("empty", payload.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("no usable text", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Rewrite_section_can_target_saved_job_context_and_whole_cv()
|
public async Task Rewrite_section_can_target_saved_job_context_and_whole_cv()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class SummarizerServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Summarize_section_uses_cv_rewrite_endpoint()
|
||||||
|
{
|
||||||
|
var handler = new CapturingHandler();
|
||||||
|
var httpClient = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("http://localhost:8001")
|
||||||
|
};
|
||||||
|
|
||||||
|
var httpFactory = new Mock<IHttpClientFactory>();
|
||||||
|
httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient);
|
||||||
|
|
||||||
|
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new SummarizerService(httpFactory.Object, memoryCache);
|
||||||
|
|
||||||
|
var result = await service.SummarizeSectionAsync("Rewrite this CV", "Professional Summary\nBuilt backend systems.", 1800, 400);
|
||||||
|
|
||||||
|
Assert.Equal("rewritten cv", result);
|
||||||
|
Assert.Equal("/cv/rewrite", handler.LastPath);
|
||||||
|
Assert.NotNull(handler.LastBody);
|
||||||
|
Assert.Contains("\"instruction\":\"Rewrite this CV\"", handler.LastBody);
|
||||||
|
Assert.Contains("\"max_length\":256", handler.LastBody);
|
||||||
|
Assert.Contains("\"min_length\":180", handler.LastBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Summarize_section_clamps_lengths_to_ai_service_limits()
|
||||||
|
{
|
||||||
|
var handler = new CapturingHandler();
|
||||||
|
var httpClient = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("http://localhost:8001")
|
||||||
|
};
|
||||||
|
|
||||||
|
var httpFactory = new Mock<IHttpClientFactory>();
|
||||||
|
httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient);
|
||||||
|
|
||||||
|
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new SummarizerService(httpFactory.Object, memoryCache);
|
||||||
|
|
||||||
|
await service.SummarizeSectionAsync("Rewrite this CV", "Professional Summary\nBuilt backend systems.", 1800, 400);
|
||||||
|
|
||||||
|
Assert.NotNull(handler.LastBody);
|
||||||
|
Assert.Contains("\"max_length\":256", handler.LastBody);
|
||||||
|
Assert.Contains("\"min_length\":180", handler.LastBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public string? LastBody { get; private set; }
|
||||||
|
public string? LastPath { get; private set; }
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastPath = request.RequestUri?.AbsolutePath;
|
||||||
|
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var responseBody = LastPath == "/cv/rewrite"
|
||||||
|
? "{\"rewritten_text\":\"rewritten cv\"}"
|
||||||
|
: "{\"summary\":\"ok\"}";
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,10 +109,14 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
public JsonElement? JobApplicationId { get; set; }
|
public JsonElement? JobApplicationId { get; set; }
|
||||||
public string? TemplateId { get; set; }
|
public string? TemplateId { get; set; }
|
||||||
public string? SourceText { get; set; }
|
public string? SourceText { get; set; }
|
||||||
|
public string? PromptBackground { get; set; }
|
||||||
|
public string? Tone { get; set; }
|
||||||
|
public string? Language { get; set; }
|
||||||
}
|
}
|
||||||
public sealed record ParseCvRequest(string? Text);
|
public sealed record ParseCvRequest(string? Text);
|
||||||
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets);
|
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets);
|
||||||
public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId);
|
public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId);
|
||||||
|
public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null);
|
||||||
|
|
||||||
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
|
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
|
||||||
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
||||||
@@ -295,6 +299,9 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
var style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim();
|
var style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim();
|
||||||
var templateId = NormalizeTemplateId(request.TemplateId ?? style);
|
var templateId = NormalizeTemplateId(request.TemplateId ?? style);
|
||||||
var targetRole = string.IsNullOrWhiteSpace(request.TargetRole) ? null : request.TargetRole.Trim();
|
var targetRole = string.IsNullOrWhiteSpace(request.TargetRole) ? null : request.TargetRole.Trim();
|
||||||
|
var tone = string.IsNullOrWhiteSpace(request.Tone) ? null : request.Tone.Trim();
|
||||||
|
var language = string.IsNullOrWhiteSpace(request.Language) ? null : request.Language.Trim();
|
||||||
|
var promptBackground = string.IsNullOrWhiteSpace(request.PromptBackground) ? null : request.PromptBackground.Trim();
|
||||||
var jobApplicationId = ParseFlexibleNullableInt(request.JobApplicationId);
|
var jobApplicationId = ParseFlexibleNullableInt(request.JobApplicationId);
|
||||||
var jobContext = jobApplicationId.HasValue
|
var jobContext = jobApplicationId.HasValue
|
||||||
? await _db.JobApplications
|
? await _db.JobApplications
|
||||||
@@ -326,9 +333,12 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
: effectiveTargetRole is not null
|
: effectiveTargetRole is not null
|
||||||
? $"Target role: {effectiveTargetRole}. Keep it broadly reusable but clearly aligned to that role family."
|
? $"Target role: {effectiveTargetRole}. Keep it broadly reusable but clearly aligned to that role family."
|
||||||
: "Keep it broadly reusable for future tailoring.";
|
: "Keep it broadly reusable for future tailoring.";
|
||||||
|
var toneGuidance = tone is not null ? $"Tone guidance: {tone}." : "Tone guidance: confident, professional, concise, and factual.";
|
||||||
|
var languageGuidance = language is not null ? $"Write the CV in {language}." : "Write the CV in English unless the source clearly requires another language.";
|
||||||
|
var backgroundGuidance = promptBackground is not null ? $"Candidate background and emphasis: {promptBackground}" : string.Empty;
|
||||||
|
|
||||||
var subject = sectionName is null ? "this CV" : $"the '{sectionName}' section of this CV";
|
var subject = sectionName is null ? "this CV" : $"the '{sectionName}' section of this CV";
|
||||||
var instruction = $"Rewrite only {subject}. Preserve facts, avoid inventing employers, titles, qualifications, dates, locations, or metrics. Style guidance: {style}. Template direction: {templateGuidance}. {roleGuidance} Return only the rewritten text with clean headings and bullets when useful.";
|
var instruction = $"Rewrite only {subject}. Preserve facts, avoid inventing employers, titles, qualifications, dates, locations, salaries, or metrics. Style guidance: {style}. Template direction: {templateGuidance}. {roleGuidance} {toneGuidance} {languageGuidance} {backgroundGuidance} Return only the rewritten CV text with clean headings and strong bullet phrasing when useful.";
|
||||||
var rewritten = await _aiService.SummarizeSectionAsync(
|
var rewritten = await _aiService.SummarizeSectionAsync(
|
||||||
instruction,
|
instruction,
|
||||||
rewriteSource,
|
rewriteSource,
|
||||||
@@ -337,9 +347,23 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(rewritten))
|
if (string.IsNullOrWhiteSpace(rewritten))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("CV rewrite returned empty output. Section={SectionName} Template={TemplateId} TargetRole={TargetRole} JobApplicationId={JobApplicationId} HasSourceText={HasSourceText} StructuredSections={StructuredSectionCount}",
|
var metrics = await _aiService.GetMetricsAsync(HttpContext.RequestAborted);
|
||||||
sectionName ?? "<whole-cv>", templateId, effectiveTargetRole ?? "<none>", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count);
|
var detail = metrics.Healthy
|
||||||
return StatusCode(StatusCodes.Status502BadGateway, "The AI service could not rewrite your CV right now.");
|
? "The rewrite request reached the AI service, but it returned no usable text."
|
||||||
|
: "The AI rewrite service is unavailable or not ready.";
|
||||||
|
var failureCode = metrics.Healthy ? "rewrite-empty" : "ai-service-unavailable";
|
||||||
|
var message = metrics.Healthy
|
||||||
|
? "The AI service returned an empty CV rewrite."
|
||||||
|
: "The AI service could not rewrite your CV right now.";
|
||||||
|
|
||||||
|
_logger.LogWarning("CV rewrite returned empty output. Section={SectionName} Template={TemplateId} TargetRole={TargetRole} JobApplicationId={JobApplicationId} HasSourceText={HasSourceText} StructuredSections={StructuredSectionCount} AiHealthy={AiHealthy} AiLastError={AiLastError}",
|
||||||
|
sectionName ?? "<whole-cv>", templateId, effectiveTargetRole ?? "<none>", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count, metrics.Healthy, metrics.LastError ?? "<none>");
|
||||||
|
|
||||||
|
return StatusCode(StatusCodes.Status502BadGateway, new CvRewriteFailureDto(
|
||||||
|
failureCode,
|
||||||
|
message,
|
||||||
|
detail,
|
||||||
|
metrics.LastError));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
@@ -2123,7 +2147,7 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
var contactSection = sections.FirstOrDefault(section => section.Name == "Contact");
|
var contactSection = sections.FirstOrDefault(section => section.Name == "Contact");
|
||||||
profile.Contact.Location = PreferDetectedLocation(contactSection.Content ?? text, profile.Contact.Location, profile.Contact.FullName);
|
profile.Contact.Location = PreferDetectedLocation(contactSection?.Content ?? text, profile.Contact.Location, profile.Contact.FullName);
|
||||||
profile.Summary = CondenseSummary(profile.Summary);
|
profile.Summary = CondenseSummary(profile.Summary);
|
||||||
profile.Skills = OrderSkills(profile.Skills);
|
profile.Skills = OrderSkills(profile.Skills);
|
||||||
profile.Interests = CleanInterestItems(profile.Interests);
|
profile.Interests = CleanInterestItems(profile.Interests);
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV ASPNETCORE_URLS=http://+:8080
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV CV_PDF_BROWSER_PATH=/usr/bin/chromium
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends chromium \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
|||||||
@@ -60,16 +60,26 @@ builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
|
|||||||
// Avoid ServerVersion.AutoDetect here because it forces an immediate DB connection
|
// Avoid ServerVersion.AutoDetect here because it forces an immediate DB connection
|
||||||
// during service registration, which can crash the API if MariaDB is temporarily
|
// during service registration, which can crash the API if MariaDB is temporarily
|
||||||
// unavailable or on a different network during deploy startup.
|
// unavailable or on a different network during deploy startup.
|
||||||
options.UseMySql(cs, new MariaDbServerVersion(new Version(11, 0, 0)));
|
options.UseMySql(cs, new MariaDbServerVersion(new Version(11, 0, 0)), mysql =>
|
||||||
|
{
|
||||||
|
mysql.MigrationsAssembly("JobTrackerApi");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
options.UseSqlite(cs);
|
options.UseSqlite(cs, sqlite =>
|
||||||
|
{
|
||||||
|
sqlite.MigrationsAssembly("JobTrackerApi");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// We create Identity tables on startup in environments where `dotnet ef` isn't available.
|
// We create Identity tables on startup in environments where `dotnet ef` isn't available.
|
||||||
// That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it.
|
// That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it.
|
||||||
options.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
|
options.ConfigureWarnings(w =>
|
||||||
|
{
|
||||||
|
w.Ignore(RelationalEventId.PendingModelChangesWarning);
|
||||||
|
w.Ignore(CoreEventId.PossibleIncorrectRequiredNavigationWithQueryFilterInteractionWarning);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enable CORS (allowlist by default)
|
// Enable CORS (allowlist by default)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Playwright;
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace JobTrackerApi.Services;
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
@@ -11,6 +12,18 @@ public interface ICvPdfExporter
|
|||||||
|
|
||||||
public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||||
{
|
{
|
||||||
|
private static readonly string[] BrowserCandidates =
|
||||||
|
{
|
||||||
|
"chromium",
|
||||||
|
"chromium-browser",
|
||||||
|
"google-chrome",
|
||||||
|
"google-chrome-stable",
|
||||||
|
"/usr/bin/chromium",
|
||||||
|
"/usr/bin/chromium-browser",
|
||||||
|
"/usr/bin/google-chrome",
|
||||||
|
"/usr/bin/google-chrome-stable"
|
||||||
|
};
|
||||||
|
|
||||||
private readonly AppPaths _paths;
|
private readonly AppPaths _paths;
|
||||||
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
|
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
|
||||||
|
|
||||||
@@ -25,42 +38,141 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
|||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
|
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
|
||||||
Directory.CreateDirectory(folder);
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
|
var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
|
||||||
? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf"
|
? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf"
|
||||||
: renderResult.SuggestedFileName;
|
: renderResult.SuggestedFileName;
|
||||||
var storagePath = Path.Combine(folder, fileName);
|
var storagePath = Path.Combine(folder, fileName);
|
||||||
|
|
||||||
|
var tempRoot = Path.Combine(Path.GetTempPath(), "jobtracker-cv-pdf", Guid.NewGuid().ToString("n"));
|
||||||
|
var htmlPath = Path.Combine(tempRoot, "document.html");
|
||||||
|
var userDataDir = Path.Combine(tempRoot, "profile");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(tempRoot);
|
||||||
|
Directory.CreateDirectory(userDataDir);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var playwright = await Playwright.CreateAsync();
|
await File.WriteAllTextAsync(htmlPath, renderResult.Html ?? string.Empty, Encoding.UTF8, cancellationToken);
|
||||||
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
|
||||||
|
var browserPath = ResolveBrowserPath();
|
||||||
|
if (string.IsNullOrWhiteSpace(browserPath))
|
||||||
{
|
{
|
||||||
Headless = true,
|
throw new InvalidOperationException("CV PDF export is unavailable. Install Chromium/Google Chrome or set CV_PDF_BROWSER_PATH.");
|
||||||
});
|
}
|
||||||
var page = await browser.NewPageAsync();
|
|
||||||
await page.SetContentAsync(renderResult.Html, new PageSetContentOptions
|
var arguments = BuildArguments(userDataDir, storagePath, htmlPath);
|
||||||
|
var startInfo = new ProcessStartInfo();
|
||||||
|
startInfo.FileName = browserPath;
|
||||||
|
startInfo.Arguments = arguments;
|
||||||
|
startInfo.RedirectStandardOutput = true;
|
||||||
|
startInfo.RedirectStandardError = true;
|
||||||
|
startInfo.UseShellExecute = false;
|
||||||
|
startInfo.CreateNoWindow = true;
|
||||||
|
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.Start();
|
||||||
|
await process.WaitForExitAsync(cancellationToken);
|
||||||
|
|
||||||
|
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||||
|
var stderr = await process.StandardError.ReadToEndAsync();
|
||||||
|
|
||||||
|
if (process.ExitCode != 0)
|
||||||
{
|
{
|
||||||
WaitUntil = WaitUntilState.Load,
|
throw new InvalidOperationException($"CV PDF export failed via browser CLI. ExitCode={process.ExitCode}. Stdout={stdout}. Stderr={stderr}");
|
||||||
});
|
}
|
||||||
var bytes = await page.PdfAsync(new PagePdfOptions
|
|
||||||
|
if (!File.Exists(storagePath))
|
||||||
{
|
{
|
||||||
Format = "A4",
|
throw new InvalidOperationException($"CV PDF export did not create the expected file at {storagePath}.");
|
||||||
PrintBackground = true,
|
}
|
||||||
Margin = new()
|
|
||||||
{
|
var bytes = await File.ReadAllBytesAsync(storagePath, cancellationToken);
|
||||||
Top = "0",
|
|
||||||
Right = "0",
|
|
||||||
Bottom = "0",
|
|
||||||
Left = "0",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await File.WriteAllBytesAsync(storagePath, bytes, cancellationToken);
|
|
||||||
return new CvPdfArtifact(fileName, storagePath, bytes);
|
return new CvPdfArtifact(fileName, storagePath, bytes);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath);
|
_logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath);
|
||||||
throw new InvalidOperationException("CV PDF export is unavailable. Ensure Chromium is installed for Playwright on this machine.", ex);
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TryDeleteDirectory(tempRoot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string BuildArguments(string userDataDir, string storagePath, string htmlPath)
|
||||||
|
{
|
||||||
|
var parts = new List<string>
|
||||||
|
{
|
||||||
|
"--headless=new",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--allow-file-access-from-files",
|
||||||
|
"--enable-local-file-accesses",
|
||||||
|
"--user-data-dir=" + Quote(userDataDir),
|
||||||
|
"--print-to-pdf=" + Quote(storagePath),
|
||||||
|
Quote(htmlPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
return string.Join(' ', parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveBrowserPath()
|
||||||
|
{
|
||||||
|
var configured = Environment.GetEnvironmentVariable("CV_PDF_BROWSER_PATH");
|
||||||
|
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||||
|
{
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var candidate in BrowserCandidates)
|
||||||
|
{
|
||||||
|
if (Path.IsPathRooted(candidate))
|
||||||
|
{
|
||||||
|
if (File.Exists(candidate)) return candidate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved = FindOnPath(candidate);
|
||||||
|
if (!string.IsNullOrWhiteSpace(resolved)) return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindOnPath(string fileName)
|
||||||
|
{
|
||||||
|
var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||||
|
var parts = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
foreach (var dir in parts)
|
||||||
|
{
|
||||||
|
var fullPath = Path.Combine(dir, fileName);
|
||||||
|
if (File.Exists(fullPath)) return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteDirectory(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
Directory.Delete(path, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best effort temp cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Quote(string value)
|
||||||
|
{
|
||||||
|
return '"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -870,7 +870,17 @@ public static class StartupInitializationExtensions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db.Database.Migrate();
|
try
|
||||||
|
{
|
||||||
|
using var migrationScope = app.Services.CreateScope();
|
||||||
|
var migrationDb = migrationScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||||
|
migrationDb.Database.Migrate();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
app.Logger.LogError(ex, "Database migration failed during startup initialization.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
// Optional: seed an initial admin user for local username/password login.
|
// Optional: seed an initial admin user for local username/password login.
|
||||||
// Set Auth:AdminEmail and Auth:AdminPassword to enable.
|
// Set Auth:AdminEmail and Auth:AdminPassword to enable.
|
||||||
@@ -878,21 +888,25 @@ public static class StartupInitializationExtensions
|
|||||||
var adminPassword = (app.Configuration["Auth:AdminPassword"] ?? "").Trim();
|
var adminPassword = (app.Configuration["Auth:AdminPassword"] ?? "").Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(adminEmail) && !string.IsNullOrWhiteSpace(adminPassword))
|
if (!string.IsNullOrWhiteSpace(adminEmail) && !string.IsNullOrWhiteSpace(adminPassword))
|
||||||
{
|
{
|
||||||
|
using var adminScope = app.Services.CreateScope();
|
||||||
|
var adminDb = adminScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||||
|
var adminUsers = adminScope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
|
var adminRoles = adminScope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||||
const string adminRole = "Admin";
|
const string adminRole = "Admin";
|
||||||
|
|
||||||
if (!roles.RoleExistsAsync(adminRole).GetAwaiter().GetResult())
|
if (!adminRoles.RoleExistsAsync(adminRole).GetAwaiter().GetResult())
|
||||||
{
|
{
|
||||||
roles.CreateAsync(new IdentityRole(adminRole)).GetAwaiter().GetResult();
|
adminRoles.CreateAsync(new IdentityRole(adminRole)).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
|
var existing = adminUsers.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
var u = new ApplicationUser { UserName = adminEmail, Email = adminEmail, EmailConfirmed = true };
|
var u = new ApplicationUser { UserName = adminEmail, Email = adminEmail, EmailConfirmed = true };
|
||||||
var created = users.CreateAsync(u, adminPassword).GetAwaiter().GetResult();
|
var created = adminUsers.CreateAsync(u, adminPassword).GetAwaiter().GetResult();
|
||||||
if (created.Succeeded)
|
if (created.Succeeded)
|
||||||
{
|
{
|
||||||
users.AddToRoleAsync(u, adminRole).GetAwaiter().GetResult();
|
adminUsers.AddToRoleAsync(u, adminRole).GetAwaiter().GetResult();
|
||||||
app.Logger.LogInformation("Seeded admin user: {Email}", adminEmail);
|
app.Logger.LogInformation("Seeded admin user: {Email}", adminEmail);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -902,17 +916,17 @@ public static class StartupInitializationExtensions
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var inRole = users.IsInRoleAsync(existing, adminRole).GetAwaiter().GetResult();
|
var inRole = adminUsers.IsInRoleAsync(existing, adminRole).GetAwaiter().GetResult();
|
||||||
if (!inRole) users.AddToRoleAsync(existing, adminRole).GetAwaiter().GetResult();
|
if (!inRole) adminUsers.AddToRoleAsync(existing, adminRole).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-time claim of legacy data for the admin user so enabling auth doesn't "hide" existing records.
|
// One-time claim of legacy data for the admin user so enabling auth doesn't "hide" existing records.
|
||||||
var admin = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
|
var admin = adminUsers.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
|
||||||
if (admin is not null)
|
if (admin is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var conn = db.Database.GetDbConnection();
|
using var conn = adminDb.Database.GetDbConnection();
|
||||||
conn.Open();
|
conn.Open();
|
||||||
|
|
||||||
static bool ColumnExists(DbConnection c, string providerName, string table, string column)
|
static bool ColumnExists(DbConnection c, string providerName, string table, string column)
|
||||||
@@ -953,12 +967,12 @@ public static class StartupInitializationExtensions
|
|||||||
{
|
{
|
||||||
if (companyOwnershipExists)
|
if (companyOwnershipExists)
|
||||||
{
|
{
|
||||||
db.Database.ExecuteSqlRaw("UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
|
adminDb.Database.ExecuteSqlRaw("UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jobOwnershipExists)
|
if (jobOwnershipExists)
|
||||||
{
|
{
|
||||||
db.Database.ExecuteSqlRaw("UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
|
adminDb.Database.ExecuteSqlRaw("UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ namespace JobTrackerApi.Services
|
|||||||
public class SummarizerService : ISummarizerService
|
public class SummarizerService : ISummarizerService
|
||||||
{
|
{
|
||||||
private const int AiSummarizeMaxInputChars = 20000;
|
private const int AiSummarizeMaxInputChars = 20000;
|
||||||
|
private const int AiServiceMaxSummaryLength = 256;
|
||||||
|
private const int AiServiceMaxMinLength = 180;
|
||||||
|
private const int AiServiceMinSummaryLength = 24;
|
||||||
|
private const int AiServiceMinMinLength = 8;
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
private readonly object _metricsLock = new();
|
private readonly object _metricsLock = new();
|
||||||
@@ -149,8 +153,7 @@ namespace JobTrackerApi.Services
|
|||||||
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
|
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<string?>(null);
|
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<string?>(null);
|
||||||
var composed = ComposeBoundedPrompt(instruction.Trim(), text.Trim());
|
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength);
|
||||||
return SummarizeCoreAsync(composed, maxLength, minLength);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComposeBoundedPrompt(string instruction, string text)
|
private static string ComposeBoundedPrompt(string instruction, string text)
|
||||||
@@ -170,9 +173,17 @@ namespace JobTrackerApi.Services
|
|||||||
return prefix + text[..remaining];
|
return prefix + text[..remaining];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> SummarizeCoreAsync(string text, int maxLength, int minLength)
|
private async Task<string?> RewriteCoreAsync(string instruction, string text, int maxLength, int minLength)
|
||||||
{
|
{
|
||||||
var key = BuildCacheKey(text, maxLength, minLength);
|
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
|
||||||
|
var normalizedMinLength = Math.Clamp(minLength, AiServiceMinMinLength, AiServiceMaxMinLength);
|
||||||
|
if (normalizedMinLength >= normalizedMaxLength)
|
||||||
|
{
|
||||||
|
normalizedMinLength = Math.Max(AiServiceMinMinLength, normalizedMaxLength - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var composed = ComposeBoundedPrompt(instruction, text);
|
||||||
|
var key = BuildCacheKey($"rewrite::{composed}", normalizedMaxLength, normalizedMinLength);
|
||||||
Interlocked.Increment(ref _requests);
|
Interlocked.Increment(ref _requests);
|
||||||
|
|
||||||
if (_cache.TryGetValue<string>(key, out var cached))
|
if (_cache.TryGetValue<string>(key, out var cached))
|
||||||
@@ -189,7 +200,95 @@ namespace JobTrackerApi.Services
|
|||||||
Interlocked.Increment(ref _cacheMisses);
|
Interlocked.Increment(ref _cacheMisses);
|
||||||
|
|
||||||
var client = _httpFactory.CreateClient("ai-service");
|
var client = _httpFactory.CreateClient("ai-service");
|
||||||
var payload = JsonSerializer.Serialize(new { text, max_length = maxLength, min_length = minLength });
|
var payload = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
instruction,
|
||||||
|
text,
|
||||||
|
max_length = normalizedMaxLength,
|
||||||
|
min_length = normalizedMinLength,
|
||||||
|
});
|
||||||
|
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var res = await client.PostAsync("/cv/rewrite", content);
|
||||||
|
sw.Stop();
|
||||||
|
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
|
||||||
|
if (!res.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await ReadErrorBodyAsync(res);
|
||||||
|
Interlocked.Increment(ref _failures);
|
||||||
|
lock (_metricsLock)
|
||||||
|
{
|
||||||
|
_lastFailureAt = DateTimeOffset.UtcNow;
|
||||||
|
_lastError = $"AI rewrite failed: {errorBody}";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = await res.Content.ReadAsStreamAsync();
|
||||||
|
using var doc = await JsonDocument.ParseAsync(stream);
|
||||||
|
if (doc.RootElement.TryGetProperty("rewritten_text", out var el))
|
||||||
|
{
|
||||||
|
var s = el.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(s)) _cache.Set(key, s, TimeSpan.FromHours(6));
|
||||||
|
lock (_metricsLock)
|
||||||
|
{
|
||||||
|
_lastSuccessAt = DateTimeOffset.UtcNow;
|
||||||
|
_lastError = null;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_metricsLock)
|
||||||
|
{
|
||||||
|
_lastFailureAt = DateTimeOffset.UtcNow;
|
||||||
|
_lastError = "AI rewrite failed: response did not contain rewritten_text.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
|
||||||
|
Interlocked.Increment(ref _failures);
|
||||||
|
lock (_metricsLock)
|
||||||
|
{
|
||||||
|
_lastFailureAt = DateTimeOffset.UtcNow;
|
||||||
|
_lastError = ex.Message;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> SummarizeCoreAsync(string text, int maxLength, int minLength)
|
||||||
|
{
|
||||||
|
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
|
||||||
|
var normalizedMinLength = Math.Clamp(minLength, AiServiceMinMinLength, AiServiceMaxMinLength);
|
||||||
|
if (normalizedMinLength >= normalizedMaxLength)
|
||||||
|
{
|
||||||
|
normalizedMinLength = Math.Max(AiServiceMinMinLength, normalizedMaxLength - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = BuildCacheKey(text, normalizedMaxLength, normalizedMinLength);
|
||||||
|
Interlocked.Increment(ref _requests);
|
||||||
|
|
||||||
|
if (_cache.TryGetValue<string>(key, out var cached))
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _cacheHits);
|
||||||
|
lock (_metricsLock)
|
||||||
|
{
|
||||||
|
_lastSuccessAt = DateTimeOffset.UtcNow;
|
||||||
|
_lastError = null;
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Increment(ref _cacheMisses);
|
||||||
|
|
||||||
|
var client = _httpFactory.CreateClient("ai-service");
|
||||||
|
var payload = JsonSerializer.Serialize(new { text, max_length = normalizedMaxLength, min_length = normalizedMinLength });
|
||||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
|
||||||
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
|
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
|
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# M015 Cross-User Authorization Replay Report
|
||||||
|
|
||||||
|
This report covers the follow-up tenant-boundary work after `M013` and `M014`.
|
||||||
|
|
||||||
|
Related artifacts:
|
||||||
|
|
||||||
|
- `docs/security-assessments/M013-adversarial-security-assessment.md`
|
||||||
|
- `docs/security-assessments/M014-security-remediation-verification.md`
|
||||||
|
- `docs/security-assessments/M015-hostile-fixture-setup.md`
|
||||||
|
- `docs/security-assessments/M015-hostile-fixture-setup.json`
|
||||||
|
- `docs/security-assessments/M015-s02-probe-results.json`
|
||||||
|
|
||||||
|
## Test Setup
|
||||||
|
|
||||||
|
A dedicated hostile-test SQLite database was created from the current EF model because the default development DB was missing core domain tables needed for real authorization probes.
|
||||||
|
|
||||||
|
Fixture runtime:
|
||||||
|
|
||||||
|
- clean SQLite DB under `.tmp/m015-fixture`
|
||||||
|
- API started with `Data__Root=/home/pi/development/JobTracker/.tmp/m015-fixture`
|
||||||
|
- registration temporarily enabled for the fixture runtime
|
||||||
|
- two real local users created through the API:
|
||||||
|
- `alice.m015@example.com`
|
||||||
|
- `bob.m015@example.com`
|
||||||
|
|
||||||
|
Alice-owned fixture resources created through the real API:
|
||||||
|
|
||||||
|
- `company_id = 1`
|
||||||
|
- `job_id = 1`
|
||||||
|
- `correspondence_id = 1`
|
||||||
|
- `attachment_id = 1`
|
||||||
|
|
||||||
|
All mutating requests used the real cookie + CSRF contract.
|
||||||
|
|
||||||
|
## Cross-User Probe Summary
|
||||||
|
|
||||||
|
Bob targeted Alice’s fixture ids with a real authenticated session.
|
||||||
|
|
||||||
|
### Defended in this pass
|
||||||
|
|
||||||
|
The following probes failed closed with `404` when Bob targeted Alice’s resources:
|
||||||
|
|
||||||
|
- `GET /api/attachments/1`
|
||||||
|
- `GET /api/attachments/download/1`
|
||||||
|
- `PATCH /api/attachments/1`
|
||||||
|
- `DELETE /api/attachments/1`
|
||||||
|
- `GET /api/correspondence/1`
|
||||||
|
- `DELETE /api/correspondence/1`
|
||||||
|
- `GET /api/jobapplications/1`
|
||||||
|
- `PUT /api/jobapplications/1`
|
||||||
|
- `PATCH /api/jobapplications/1/followup`
|
||||||
|
- `GET /api/jobapplications/1/timeline`
|
||||||
|
- `GET /api/jobapplications/1/tailored-cv-draft`
|
||||||
|
- `GET /api/jobapplications/1/followup-draft`
|
||||||
|
|
||||||
|
These routes did not expose or mutate Alice-owned data in this hostile fixture pass.
|
||||||
|
|
||||||
|
## Confirmed Finding
|
||||||
|
|
||||||
|
### Cross-user read leak on job history
|
||||||
|
|
||||||
|
- **Category:** Authorization / data exposure
|
||||||
|
- **Endpoint:** `GET /api/jobapplications/{id}/history`
|
||||||
|
- **Risk:** **Medium**
|
||||||
|
|
||||||
|
#### Vulnerability
|
||||||
|
|
||||||
|
Before the fix, Bob could request Alice’s job history by raw job id and receive Alice’s `JobEvent` rows.
|
||||||
|
|
||||||
|
Observed pre-fix response:
|
||||||
|
|
||||||
|
- `GET /api/jobapplications/1/history` as Bob
|
||||||
|
- `200 OK`
|
||||||
|
- payload included Alice-owned event data, including the `Created` event for Alice’s job
|
||||||
|
|
||||||
|
#### Example exploit input
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/jobapplications/1/history
|
||||||
|
Cookie: jobtracker_auth=<bob session cookie>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Root cause
|
||||||
|
|
||||||
|
Two issues combined:
|
||||||
|
|
||||||
|
1. `GetHistory(...)` queried `JobEvents` directly by `JobApplicationId` without verifying that the parent job belonged to the current user.
|
||||||
|
2. `JobEvent` had no owner-scoped query filter in `Data/JobTrackerContext.cs`.
|
||||||
|
|
||||||
|
#### Fix
|
||||||
|
|
||||||
|
- `GetHistory(...)` now checks whether the requested job exists in the current user’s scoped `JobApplications` query and returns `404` if it does not.
|
||||||
|
- `JobEvent` now has an owner-scoped query filter tied to `JobApplication.OwnerUserId`.
|
||||||
|
- Added focused regression test:
|
||||||
|
- `JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs`
|
||||||
|
|
||||||
|
#### Replay after fix
|
||||||
|
|
||||||
|
Observed post-fix response:
|
||||||
|
|
||||||
|
- `GET /api/jobapplications/1/history` as Bob
|
||||||
|
- `404 Not Found`
|
||||||
|
|
||||||
|
#### Verdict
|
||||||
|
|
||||||
|
**Fixed.**
|
||||||
|
|
||||||
|
## Automated Evidence
|
||||||
|
|
||||||
|
### Focused regression test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter JobApplicationsAuthorizationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
- passed
|
||||||
|
- verifies `GetHistory` returns `NotFound` for another user’s job
|
||||||
|
|
||||||
|
## Final Assessment
|
||||||
|
|
||||||
|
For the prioritized raw-id authorization seams exercised in this milestone:
|
||||||
|
|
||||||
|
- **confirmed and fixed:** `GET /api/jobapplications/{id}/history`
|
||||||
|
- **no finding in this fixture pass:** attachments, correspondence, primary job read/update, follow-up patch, timeline, tailored draft, follow-up draft
|
||||||
|
|
||||||
|
## Remaining Boundary
|
||||||
|
|
||||||
|
This report covers the endpoints actually exercised in the hostile fixture pass. It does **not** claim that every authorization-sensitive route in the application has been exhaustively proven safe; it closes the high-risk raw-id seams prioritized from the earlier assessment with a real two-user runtime and replay evidence.
|
||||||
@@ -1,26 +1,37 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { clearAuthClientState, getCsrfToken } from "./auth";
|
import { clearAuthClientState, getCsrfToken } from "./auth";
|
||||||
|
|
||||||
|
function looksLikeHtml(value: string) {
|
||||||
|
return /<\s*html\b|<\s*body\b|<\s*head\b|<\s*title\b|<\s*!doctype\b/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeServerMessage(value: string, fallback: string) {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) return fallback;
|
||||||
|
if (looksLikeHtml(text)) return fallback;
|
||||||
|
return text.length > 300 ? `${text.slice(0, 297).trimEnd()}...` : text;
|
||||||
|
}
|
||||||
|
|
||||||
export function getApiErrorMessage(error: any, fallback = "Request failed.") {
|
export function getApiErrorMessage(error: any, fallback = "Request failed.") {
|
||||||
const data = error?.response?.data;
|
const data = error?.response?.data;
|
||||||
if (typeof data === "string" && data.trim()) return data.trim();
|
if (typeof data === "string" && data.trim()) return sanitizeServerMessage(data, fallback);
|
||||||
if (typeof data?.message === "string" && data.message.trim()) return data.message.trim();
|
if (typeof data?.message === "string" && data.message.trim()) return sanitizeServerMessage(data.message, fallback);
|
||||||
if (typeof data?.detail === "string" && data.detail.trim()) return data.detail.trim();
|
if (typeof data?.detail === "string" && data.detail.trim()) return sanitizeServerMessage(data.detail, fallback);
|
||||||
if (typeof data?.title === "string" && data.title.trim()) return data.title.trim();
|
if (typeof data?.title === "string" && data.title.trim()) return sanitizeServerMessage(data.title, fallback);
|
||||||
if (Array.isArray(data?.errors)) {
|
if (Array.isArray(data?.errors)) {
|
||||||
const first = data.errors.find((value: unknown) => typeof value === "string" && value.trim());
|
const first = data.errors.find((value: unknown) => typeof value === "string" && value.trim());
|
||||||
if (first) return first;
|
if (first) return sanitizeServerMessage(first, fallback);
|
||||||
}
|
}
|
||||||
if (data?.errors && typeof data.errors === "object") {
|
if (data?.errors && typeof data.errors === "object") {
|
||||||
for (const value of Object.values(data.errors)) {
|
for (const value of Object.values(data.errors)) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const first = value.find((item: unknown) => typeof item === "string" && item.trim());
|
const first = value.find((item: unknown) => typeof item === "string" && item.trim());
|
||||||
if (first) return first;
|
if (first) return sanitizeServerMessage(first, fallback);
|
||||||
}
|
}
|
||||||
if (typeof value === "string" && value.trim()) return value.trim();
|
if (typeof value === "string" && value.trim()) return sanitizeServerMessage(value, fallback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof error?.message === "string" && error.message.trim()) return error.message.trim();
|
if (typeof error?.message === "string" && error.message.trim()) return sanitizeServerMessage(error.message, fallback);
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|||||||
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
||||||
import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||||
import CropImageDialog from "../components/CropImageDialog";
|
import CropImageDialog from "../components/CropImageDialog";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -28,6 +28,9 @@ import { JobApplication } from "../types";
|
|||||||
type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
||||||
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord";
|
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord";
|
||||||
|
|
||||||
|
type CvBuilderTone = "Concise and direct" | "Executive and polished" | "Technical and detailed" | "Warm and people-focused";
|
||||||
|
type CvBuilderLanguage = "English" | "Norwegian" | "Spanish" | "French" | "German";
|
||||||
|
|
||||||
type ExtractionRun = {
|
type ExtractionRun = {
|
||||||
id: number;
|
id: number;
|
||||||
trigger: string;
|
trigger: string;
|
||||||
@@ -78,6 +81,27 @@ type CvBuilderPreview = {
|
|||||||
jobApplicationId?: number | null;
|
jobApplicationId?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PdfCarouselItem = {
|
||||||
|
templateId: CvSectionStyle;
|
||||||
|
title: string;
|
||||||
|
fileName: string;
|
||||||
|
pdfUrl?: string;
|
||||||
|
status: "loading" | "ready" | "error";
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RewriteRequestPayload = {
|
||||||
|
sectionName: string | null;
|
||||||
|
style: CvSectionStyle;
|
||||||
|
templateId: CvSectionStyle;
|
||||||
|
targetRole: string | null;
|
||||||
|
jobApplicationId: number | null;
|
||||||
|
sourceText: string | null;
|
||||||
|
promptBackground: string | null;
|
||||||
|
tone: string | null;
|
||||||
|
language: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type MeResponse = {
|
type MeResponse = {
|
||||||
provider?: "local" | "google" | "external";
|
provider?: "local" | "google" | "external";
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -224,9 +248,16 @@ export default function ProfilePage() {
|
|||||||
const [cvSection, setCvSection] = useState<CvSectionOption>("");
|
const [cvSection, setCvSection] = useState<CvSectionOption>("");
|
||||||
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
|
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
|
||||||
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
||||||
|
const [cvPromptBackground, setCvPromptBackground] = useState("");
|
||||||
|
const [cvTone, setCvTone] = useState<CvBuilderTone>("Concise and direct");
|
||||||
|
const [cvLanguage, setCvLanguage] = useState<CvBuilderLanguage>("English");
|
||||||
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
|
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
|
||||||
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
|
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
|
||||||
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
|
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
|
||||||
|
const [pdfCarousel, setPdfCarousel] = useState<PdfCarouselItem[]>([]);
|
||||||
|
const [activePdfIndex, setActivePdfIndex] = useState(0);
|
||||||
|
const [buildingPdfDeck, setBuildingPdfDeck] = useState(false);
|
||||||
|
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
||||||
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
|
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
|
||||||
const [parsingCvSections, setParsingCvSections] = useState(false);
|
const [parsingCvSections, setParsingCvSections] = useState(false);
|
||||||
const [reprocessingCv, setReprocessingCv] = useState(false);
|
const [reprocessingCv, setReprocessingCv] = useState(false);
|
||||||
@@ -236,6 +267,16 @@ export default function ProfilePage() {
|
|||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
pdfCarousel.forEach((item) => {
|
||||||
|
if (item.pdfUrl) {
|
||||||
|
window.URL.revokeObjectURL(item.pdfUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [pdfCarousel]);
|
||||||
|
|
||||||
const loadProfile = useCallback(async () => {
|
const loadProfile = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -312,6 +353,103 @@ export default function ProfilePage() {
|
|||||||
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
|
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
|
||||||
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
|
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
|
||||||
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
|
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
|
||||||
|
const activePdfItem = pdfCarousel[activePdfIndex] ?? null;
|
||||||
|
|
||||||
|
const releasePdfCarousel = useCallback((items: PdfCarouselItem[]) => {
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.pdfUrl) {
|
||||||
|
window.URL.revokeObjectURL(item.pdfUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildRewritePayload = useCallback((templateId: CvSectionStyle): RewriteRequestPayload => ({
|
||||||
|
sectionName: cvSection || null,
|
||||||
|
style: templateId,
|
||||||
|
templateId,
|
||||||
|
targetRole: cvSectionTargetRole.trim() || null,
|
||||||
|
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||||
|
sourceText: profileCvText.trim() || null,
|
||||||
|
promptBackground: cvPromptBackground.trim() || null,
|
||||||
|
tone: cvTone,
|
||||||
|
language: cvLanguage,
|
||||||
|
}), [cvLanguage, cvPromptBackground, cvSection, cvSectionTargetRole, cvTone, profileCvText, selectedRewriteJob]);
|
||||||
|
|
||||||
|
const resetPdfCarousel = useCallback(() => {
|
||||||
|
setPdfCarousel((current) => {
|
||||||
|
releasePdfCarousel(current);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
setActivePdfIndex(0);
|
||||||
|
}, [releasePdfCarousel]);
|
||||||
|
|
||||||
|
const savePdfToCarousel = useCallback(async (templateId: CvSectionStyle, download = false) => {
|
||||||
|
const template = REWRITE_TEMPLATES.find((option) => option.id === templateId) ?? REWRITE_TEMPLATES[0];
|
||||||
|
const payload = buildRewritePayload(templateId);
|
||||||
|
const response = await api.post("/profile-cv/export-pdf", payload, { responseType: "blob" });
|
||||||
|
const blob = new Blob([response.data], { type: "application/pdf" });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const item: PdfCarouselItem = {
|
||||||
|
templateId,
|
||||||
|
title: template.title,
|
||||||
|
fileName: rewritePreview?.suggestedFileName || `${templateId}-cv.pdf`,
|
||||||
|
pdfUrl: url,
|
||||||
|
status: "ready",
|
||||||
|
};
|
||||||
|
|
||||||
|
setPdfCarousel((current) => {
|
||||||
|
const existing = current.find((entry) => entry.templateId === templateId);
|
||||||
|
if (existing?.pdfUrl) {
|
||||||
|
window.URL.revokeObjectURL(existing.pdfUrl);
|
||||||
|
}
|
||||||
|
const next = existing
|
||||||
|
? current.map((entry) => (entry.templateId === templateId ? item : entry))
|
||||||
|
: [...current, item];
|
||||||
|
setActivePdfIndex(next.findIndex((entry) => entry.templateId === templateId));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (download) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = item.fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}, [buildRewritePayload, rewritePreview?.suggestedFileName]);
|
||||||
|
|
||||||
|
const buildPdfCarousel = useCallback(async () => {
|
||||||
|
setBuildingPdfDeck(true);
|
||||||
|
resetPdfCarousel();
|
||||||
|
const orderedTemplates = [selectedRewriteTemplate.id, ...REWRITE_TEMPLATES.map((option) => option.id).filter((id) => id !== selectedRewriteTemplate.id)];
|
||||||
|
const seedItems = orderedTemplates.map((templateId) => ({
|
||||||
|
templateId,
|
||||||
|
title: REWRITE_TEMPLATES.find((option) => option.id === templateId)?.title ?? templateId,
|
||||||
|
fileName: `${templateId}-cv.pdf`,
|
||||||
|
status: "loading" as const,
|
||||||
|
}));
|
||||||
|
setPdfCarousel(seedItems);
|
||||||
|
setActivePdfIndex(0);
|
||||||
|
|
||||||
|
for (const templateId of orderedTemplates) {
|
||||||
|
try {
|
||||||
|
const item = await savePdfToCarousel(templateId, false);
|
||||||
|
setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? item : entry));
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = getApiErrorMessage(error, `Failed to generate the ${templateId} PDF preview.`);
|
||||||
|
setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? { ...entry, status: "error", error: message } : entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setBuildingPdfDeck(false);
|
||||||
|
}, [resetPdfCarousel, savePdfToCarousel, selectedRewriteTemplate.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetPdfCarousel();
|
||||||
|
}, [rewritePreview?.fullText, rewritePreview?.templateId, rewritePreview?.targetRole, resetPdfCarousel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 0, p: 2.5 }}>
|
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||||
@@ -811,56 +949,109 @@ export default function ProfilePage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
{REWRITE_TEMPLATES.map((option) => {
|
<Paper sx={{ p: { xs: 1.5, md: 2 }, borderRadius: 4, border: "1px solid", borderColor: "divider", background: `linear-gradient(180deg, ${selectedRewriteTemplate.accent}14 0%, rgba(255,255,255,0.96) 100%)`, boxShadow: "0 18px 40px rgba(15,23,42,0.08)" }}>
|
||||||
const selected = option.id === cvSectionStyle;
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.15fr 0.85fr" }, gap: 2, alignItems: "stretch" }}>
|
||||||
return (
|
<Box sx={{ p: { xs: 1.25, md: 2 }, borderRadius: 3.5, background: "rgba(255,255,255,0.82)", border: "1px solid", borderColor: "rgba(15,23,42,0.08)" }}>
|
||||||
<Paper
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1.5, mb: 1.5 }}>
|
||||||
key={option.id}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => setCvSectionStyle(option.id)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
setCvSectionStyle(option.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: 3.5,
|
|
||||||
cursor: "pointer",
|
|
||||||
border: "1px solid",
|
|
||||||
borderColor: selected ? "primary.main" : "divider",
|
|
||||||
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.18), 0 12px 30px rgba(15,23,42,0.08)" : "0 6px 18px rgba(15,23,42,0.04)",
|
|
||||||
background: selected ? `linear-gradient(180deg, ${option.accent}12 0%, rgba(255,255,255,0.96) 100%)` : "background.paper",
|
|
||||||
transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease",
|
|
||||||
'&:hover': { transform: 'translateY(-2px)' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1, mb: 1 }}>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 900, letterSpacing: '0.14em' }}>{option.eyebrow}</Typography>
|
<Typography variant="overline" sx={{ color: selectedRewriteTemplate.accent, fontWeight: 900, letterSpacing: '0.16em' }}>{selectedRewriteTemplate.eyebrow}</Typography>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 900 }}>{selectedRewriteTemplate.title}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, maxWidth: 560 }}>{selectedRewriteTemplate.blurb}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="small" onClick={(event) => { event.stopPropagation(); setRewritePreviewTemplate(option); }}>
|
<IconButton size="small" onClick={() => setRewritePreviewTemplate(selectedRewriteTemplate)}>
|
||||||
<ZoomInOutlinedIcon fontSize="small" />
|
<ZoomInOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ p: 1.25, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", minHeight: 160 }}>
|
|
||||||
<Typography variant="caption" sx={{ display: "block", color: option.accent, fontWeight: 800, mb: 0.5 }}>{option.sampleHeading}</Typography>
|
<Box sx={{ borderRadius: 3.5, overflow: "hidden", border: "1px solid", borderColor: "rgba(15,23,42,0.1)", background: "white", minHeight: { xs: 280, md: 340 }, boxShadow: "inset 0 1px 0 rgba(255,255,255,0.7)" }}>
|
||||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mb: 1 }}>{option.sampleMeta}</Typography>
|
<Box sx={{ px: { xs: 2, md: 3 }, py: { xs: 2, md: 2.5 }, borderBottom: "1px solid", borderColor: "rgba(15,23,42,0.08)", background: `linear-gradient(135deg, ${selectedRewriteTemplate.accent}14 0%, rgba(255,255,255,0.96) 72%)` }}>
|
||||||
{option.sampleBullets.map((bullet) => (
|
<Typography variant="caption" sx={{ display: "block", color: selectedRewriteTemplate.accent, fontWeight: 900, letterSpacing: '0.14em', mb: 0.5 }}>{selectedRewriteTemplate.eyebrow}</Typography>
|
||||||
<Typography key={bullet} variant="caption" sx={{ display: "block", color: "text.primary", mb: 0.5 }}>• {bullet}</Typography>
|
<Typography sx={{ fontSize: { xs: '1.1rem', md: '1.35rem' }, fontWeight: 900, lineHeight: 1.1 }}>{selectedRewriteTemplate.sampleHeading}</Typography>
|
||||||
))}
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>{selectedRewriteTemplate.sampleMeta}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ px: { xs: 2, md: 3 }, py: { xs: 2, md: 2.5 } }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Preview of the generated PDF style</Typography>
|
||||||
|
{selectedRewriteTemplate.sampleBullets.map((bullet) => (
|
||||||
|
<Typography key={bullet} variant="body2" sx={{ display: "block", color: "text.primary", mb: 0.85, lineHeight: 1.55 }}>• {bullet}</Typography>
|
||||||
|
))}
|
||||||
|
<Box sx={{ mt: 2, pt: 1.5, borderTop: "1px dashed", borderColor: "divider", display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, minmax(0, 1fr))" }, gap: 1 }}>
|
||||||
|
<Chip size="small" variant="outlined" label="Readable hierarchy" />
|
||||||
|
<Chip size="small" variant="outlined" label="PDF-first spacing" />
|
||||||
|
<Chip size="small" variant="outlined" label="ATS-safe structure" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{option.blurb}</Typography>
|
</Box>
|
||||||
</Paper>
|
|
||||||
);
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.1 }}>
|
||||||
})}
|
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Choose a visual direction before generating</Typography>
|
||||||
|
<Box sx={{ display: "grid", gap: 1.1 }}>
|
||||||
|
{REWRITE_TEMPLATES.map((option) => {
|
||||||
|
const selected = option.id === cvSectionStyle;
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={option.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${option.title} template preview`}
|
||||||
|
onClick={() => setCvSectionStyle(option.id)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
setCvSectionStyle(option.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
p: 1.15,
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: selected ? "primary.main" : "divider",
|
||||||
|
background: selected ? `linear-gradient(180deg, ${option.accent}10 0%, rgba(255,255,255,0.98) 100%)` : "rgba(255,255,255,0.84)",
|
||||||
|
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.16), 0 10px 24px rgba(15,23,42,0.08)" : "0 6px 16px rgba(15,23,42,0.04)",
|
||||||
|
transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease",
|
||||||
|
'&:hover': { transform: 'translateY(-1px)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: "92px minmax(0, 1fr)", gap: 1.1, alignItems: "stretch" }}>
|
||||||
|
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "rgba(15,23,42,0.08)", background: `linear-gradient(180deg, ${option.accent}1e 0%, rgba(255,255,255,0.98) 100%)`, p: 1, minHeight: 102, display: "flex", flexDirection: "column", justifyContent: "space-between" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: option.accent, fontWeight: 900, letterSpacing: '0.08em' }}>{option.eyebrow}</Typography>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ display: "block", fontWeight: 800, lineHeight: 1.25 }}>{option.sampleHeading}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.5, lineHeight: 1.25 }}>{option.sampleMeta}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, lineHeight: 1.4 }}>{option.blurb}</Typography>
|
||||||
|
</Box>
|
||||||
|
{selected ? <Chip size="small" color="primary" label="Selected" /> : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.75 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.75 }}>
|
||||||
|
<TextField
|
||||||
|
label="Prompt-based CV brief"
|
||||||
|
value={cvPromptBackground}
|
||||||
|
onChange={(e) => setCvPromptBackground(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={4}
|
||||||
|
helperText="Describe your strengths, preferred emphasis, industry background, or the angle you want the AI to lean into."
|
||||||
|
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
<FormControl fullWidth size="small">
|
<FormControl fullWidth size="small">
|
||||||
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
|
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
|
||||||
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
|
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
|
||||||
@@ -879,6 +1070,25 @@ export default function ProfilePage() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."}
|
helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."}
|
||||||
/>
|
/>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Language</InputLabel>
|
||||||
|
<Select value={cvLanguage} label="Language" onChange={(e) => setCvLanguage(e.target.value as CvBuilderLanguage)}>
|
||||||
|
<MenuItem value="English">English</MenuItem>
|
||||||
|
<MenuItem value="Norwegian">Norwegian</MenuItem>
|
||||||
|
<MenuItem value="Spanish">Spanish</MenuItem>
|
||||||
|
<MenuItem value="French">French</MenuItem>
|
||||||
|
<MenuItem value="German">German</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Tone</InputLabel>
|
||||||
|
<Select value={cvTone} label="Tone" onChange={(e) => setCvTone(e.target.value as CvBuilderTone)}>
|
||||||
|
<MenuItem value="Concise and direct">Concise and direct</MenuItem>
|
||||||
|
<MenuItem value="Executive and polished">Executive and polished</MenuItem>
|
||||||
|
<MenuItem value="Technical and detailed">Technical and detailed</MenuItem>
|
||||||
|
<MenuItem value="Warm and people-focused">Warm and people-focused</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
||||||
<InputLabel>Saved job context</InputLabel>
|
<InputLabel>Saved job context</InputLabel>
|
||||||
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
|
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
|
||||||
@@ -903,19 +1113,13 @@ export default function ProfilePage() {
|
|||||||
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setRewritingSection(true);
|
setRewritingSection(true);
|
||||||
|
resetPdfCarousel();
|
||||||
try {
|
try {
|
||||||
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", {
|
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", buildRewritePayload(cvSectionStyle));
|
||||||
sectionName: cvSection || null,
|
|
||||||
style: cvSectionStyle,
|
|
||||||
templateId: cvSectionStyle,
|
|
||||||
targetRole: cvSectionTargetRole.trim() || null,
|
|
||||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
|
||||||
sourceText: profileCvText.trim() || null,
|
|
||||||
});
|
|
||||||
setRewritePreview(res.data);
|
setRewritePreview(res.data);
|
||||||
toast(t("profileCvSectionRewritten"), "success");
|
toast(t("profileCvSectionRewritten"), "success");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
|
toast(getApiErrorMessage(e, t("profileCvSectionRewriteFailed")), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setRewritingSection(false);
|
setRewritingSection(false);
|
||||||
}
|
}
|
||||||
@@ -925,33 +1129,27 @@ export default function ProfilePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
disabled={!rewriteReady}
|
disabled={!rewriteReady || downloadingPdf}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
setDownloadingPdf(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.post("/profile-cv/export-pdf", {
|
await savePdfToCarousel(cvSectionStyle, true);
|
||||||
sectionName: cvSection || null,
|
toast("CV PDF downloaded and added to the carousel.", "success");
|
||||||
style: cvSectionStyle,
|
|
||||||
templateId: cvSectionStyle,
|
|
||||||
targetRole: cvSectionTargetRole.trim() || null,
|
|
||||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
|
||||||
sourceText: profileCvText.trim() || null,
|
|
||||||
}, { responseType: "blob" });
|
|
||||||
const blob = new Blob([response.data], { type: "application/pdf" });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = rewritePreview?.suggestedFileName || `${cvSectionStyle}-cv.pdf`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
toast("CV PDF downloaded.", "success");
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(String(e?.response?.data || e?.message || "Failed to export the CV PDF."), "error");
|
toast(getApiErrorMessage(e, "Failed to export the CV PDF."), "error");
|
||||||
|
} finally {
|
||||||
|
setDownloadingPdf(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Download PDF
|
{downloadingPdf ? "Generating PDF…" : "Download PDF"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
disabled={!rewriteReady || buildingPdfDeck}
|
||||||
|
onClick={buildPdfCarousel}
|
||||||
|
>
|
||||||
|
{buildingPdfDeck ? "Building PDF carousel…" : "Build PDF carousel"}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -987,22 +1185,64 @@ export default function ProfilePage() {
|
|||||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Styled preview</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>PDF carousel</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · print-ready layout</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
{activePdfItem?.title ? `${activePdfItem.title} · generated PDF` : `${selectedRewriteTemplate.title} · print-ready layout`}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
|
{activePdfItem?.fileName ? <Chip size="small" variant="outlined" label={activePdfItem.fileName} /> : rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
|
||||||
{rewriteReady ? (
|
{pdfCarousel.length > 0 ? (
|
||||||
<iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
<>
|
||||||
) : (
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.25 }}>
|
||||||
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
{pdfCarousel.map((item, index) => (
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}>
|
<Button
|
||||||
The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, spacing, and hierarchy before you apply it.
|
key={item.templateId}
|
||||||
</Typography>
|
size="small"
|
||||||
|
variant={index === activePdfIndex ? "contained" : "outlined"}
|
||||||
|
color={item.status === "error" ? "error" : item.status === "ready" ? "primary" : "inherit"}
|
||||||
|
onClick={() => setActivePdfIndex(index)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
||||||
</Box>
|
{activePdfItem?.status === "ready" && activePdfItem.pdfUrl ? (
|
||||||
|
<iframe title={`${activePdfItem.title} PDF preview`} src={activePdfItem.pdfUrl} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
||||||
|
) : activePdfItem?.status === "error" ? (
|
||||||
|
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||||
|
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem.title} PDF unavailable</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{activePdfItem.error || "This template could not be rendered as a PDF right now."}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||||
|
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem?.title || "Preparing PDF preview"}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
{buildingPdfDeck ? "The carousel is generating PDFs across the current template set." : "Generate the PDF carousel to inspect rendered export files without leaving the page."}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
||||||
|
{rewriteReady ? (
|
||||||
|
<iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}>
|
||||||
|
The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, then generate the PDF carousel to compare rendered files template by template.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ import { I18nProvider } from './i18n/I18nProvider';
|
|||||||
import ProfilePage from './pages/ProfilePage';
|
import ProfilePage from './pages/ProfilePage';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
|
||||||
|
const createObjectURLMock = jest.fn(() => 'blob:mock-pdf');
|
||||||
|
const revokeObjectURLMock = jest.fn();
|
||||||
|
Object.defineProperty(window.URL, 'createObjectURL', {
|
||||||
|
writable: true,
|
||||||
|
value: createObjectURLMock,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window.URL, 'revokeObjectURL', {
|
||||||
|
writable: true,
|
||||||
|
value: revokeObjectURLMock,
|
||||||
|
});
|
||||||
|
|
||||||
jest.mock('./api', () => ({
|
jest.mock('./api', () => ({
|
||||||
api: {
|
api: {
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
@@ -22,6 +33,8 @@ jest.mock('./components/CropImageDialog', () => () => null);
|
|||||||
|
|
||||||
const mockedApi = api as jest.Mocked<typeof api>;
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
const REWRITE_TEMPLATES_COUNT = 6;
|
||||||
|
|
||||||
const structuredCv = {
|
const structuredCv = {
|
||||||
version: '1',
|
version: '1',
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -131,7 +144,7 @@ beforeEach(() => {
|
|||||||
}
|
}
|
||||||
return Promise.resolve({ data: {} } as any);
|
return Promise.resolve({ data: {} } as any);
|
||||||
});
|
});
|
||||||
mockedApi.post.mockImplementation((url: string) => {
|
mockedApi.post.mockImplementation((url: string, payload?: any, config?: any) => {
|
||||||
if (url === '/profile-cv/parse') {
|
if (url === '/profile-cv/parse') {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
data: {
|
data: {
|
||||||
@@ -149,6 +162,9 @@ beforeEach(() => {
|
|||||||
if (url === '/profile-cv/rewrite-preview') {
|
if (url === '/profile-cv/rewrite-preview') {
|
||||||
return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any);
|
return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any);
|
||||||
}
|
}
|
||||||
|
if (url === '/profile-cv/export-pdf') {
|
||||||
|
return Promise.resolve({ data: new Blob([`pdf-${payload?.templateId ?? 'ats-minimal'}`], { type: 'application/pdf' }), config } as any);
|
||||||
|
}
|
||||||
if (url === '/profile-cv/reprocess') {
|
if (url === '/profile-cv/reprocess') {
|
||||||
return Promise.resolve({ data: { reprocessed: true } } as any);
|
return Promise.resolve({ data: { reprocessed: true } } as any);
|
||||||
}
|
}
|
||||||
@@ -160,6 +176,8 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
createObjectURLMock.mockClear();
|
||||||
|
revokeObjectURLMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('profile page loads persisted structured cv and can re-parse it', async () => {
|
test('profile page loads persisted structured cv and can re-parse it', async () => {
|
||||||
@@ -230,9 +248,8 @@ test('profile page rewrite tools use selected template and saved job context', a
|
|||||||
|
|
||||||
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
|
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByText(/harvard/i));
|
fireEvent.click(screen.getByText(/harvard/i));
|
||||||
fireEvent.mouseDown(screen.getAllByRole('combobox')[1]);
|
fireEvent.change(screen.getByLabelText(/prompt-based cv brief/i), { target: { value: 'Highlight backend platform ownership, distributed systems, and cross-team delivery.' } });
|
||||||
fireEvent.click(await screen.findByText(/senior backend engineer · acme systems/i));
|
fireEvent.change(screen.getByLabelText(/target role/i), { target: { value: 'Senior Platform Engineer' } });
|
||||||
|
|
||||||
const rewriteButton = screen.getByRole('button', { name: /build preview/i });
|
const rewriteButton = screen.getByRole('button', { name: /build preview/i });
|
||||||
fireEvent.click(rewriteButton);
|
fireEvent.click(rewriteButton);
|
||||||
|
|
||||||
@@ -241,12 +258,27 @@ test('profile page rewrite tools use selected template and saved job context', a
|
|||||||
sectionName: null,
|
sectionName: null,
|
||||||
style: 'harvard',
|
style: 'harvard',
|
||||||
templateId: 'harvard',
|
templateId: 'harvard',
|
||||||
jobApplicationId: 42,
|
jobApplicationId: null,
|
||||||
|
promptBackground: 'Highlight backend platform ownership, distributed systems, and cross-team delivery.',
|
||||||
|
targetRole: 'Senior Platform Engineer',
|
||||||
|
language: 'English',
|
||||||
|
tone: 'Concise and direct',
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
|
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
|
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('heading', { name: /pdf carousel/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
const buildCarouselButton = screen.getByRole('button', { name: /build pdf carousel/i });
|
||||||
|
fireEvent.click(buildCarouselButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const exportCalls = mockedApi.post.mock.calls.filter(([url]) => url === '/profile-cv/export-pdf');
|
||||||
|
expect(exportCalls.length).toBe(REWRITE_TEMPLATES_COUNT);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(createObjectURLMock).toHaveBeenCalledTimes(REWRITE_TEMPLATES_COUNT));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('saving profile persists structured cv json', async () => {
|
test('saving profile persists structured cv json', async () => {
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ jest.mock('./api', () => ({
|
|||||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||||
},
|
},
|
||||||
getApiErrorMessage: jest.fn((error: any, fallback?: string) => {
|
getApiErrorMessage: jest.fn((error: any, fallback?: string) => {
|
||||||
if (typeof error?.response?.data === 'string' && error.response.data.trim()) return error.response.data;
|
const text = typeof error?.response?.data === 'string' && error.response.data.trim()
|
||||||
if (typeof error?.message === 'string' && error.message.trim()) return error.message;
|
? error.response.data.trim()
|
||||||
return fallback || 'Request failed.';
|
: typeof error?.message === 'string' && error.message.trim()
|
||||||
|
? error.message.trim()
|
||||||
|
: '';
|
||||||
|
if (!text) return fallback || 'Request failed.';
|
||||||
|
if (/<\s*html\b|<\s*body\b|<\s*head\b|<\s*title\b|<\s*!doctype\b/i.test(text)) return fallback || 'Request failed.';
|
||||||
|
return text.length > 300 ? `${text.slice(0, 297).trimEnd()}...` : text;
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ class SummarizeRequest(BaseModel):
|
|||||||
top_skills: int = Field(default=8, ge=3, le=12)
|
top_skills: int = Field(default=8, ge=3, le=12)
|
||||||
|
|
||||||
|
|
||||||
|
class RewriteRequest(BaseModel):
|
||||||
|
instruction: str = Field(min_length=1, max_length=6000)
|
||||||
|
text: str = Field(min_length=1, max_length=MAX_INPUT_CHARS)
|
||||||
|
max_length: int = Field(default=220, ge=24, le=256)
|
||||||
|
min_length: int = Field(default=80, ge=8, le=180)
|
||||||
|
|
||||||
|
|
||||||
class CvNormalizeRequest(BaseModel):
|
class CvNormalizeRequest(BaseModel):
|
||||||
text: str = Field(min_length=1, max_length=50000)
|
text: str = Field(min_length=1, max_length=50000)
|
||||||
|
|
||||||
@@ -424,6 +431,39 @@ def _ollama_generate_json(prompt: str):
|
|||||||
raise HTTPException(status_code=502, detail="Ollama did not return valid JSON.")
|
raise HTTPException(status_code=502, detail="Ollama did not return valid JSON.")
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_generate_text(prompt: str) -> str:
|
||||||
|
if not OLLAMA_MODEL:
|
||||||
|
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:
|
||||||
|
raise HTTPException(status_code=502, detail="Ollama returned an empty rewrite.")
|
||||||
|
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
@app.post("/cv/normalize")
|
@app.post("/cv/normalize")
|
||||||
async def normalize_cv(req: CvNormalizeRequest):
|
async def normalize_cv(req: CvNormalizeRequest):
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
@@ -536,6 +576,49 @@ Block:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/cv/rewrite")
|
||||||
|
async def rewrite_cv(req: RewriteRequest):
|
||||||
|
prompt = f"""
|
||||||
|
You are an expert CV and resume writer.
|
||||||
|
Rewrite the candidate CV into a polished, factual CV tailored to the target role.
|
||||||
|
Return ONLY the final CV text. No analysis. No commentary. No JSON. No markdown code fences. No recruiter notes.
|
||||||
|
|
||||||
|
Non-negotiable rules:
|
||||||
|
- Preserve facts only. Never invent employers, dates, locations, salaries, education, qualifications, technologies, metrics, or achievements.
|
||||||
|
- Never output sections like 'Role summary', 'What the company wants most', 'Keywords to mirror', 'Interview focus', 'Top hard skills', or similar analysis headings.
|
||||||
|
- Do not describe the job ad. Rewrite the candidate CV.
|
||||||
|
- Use crisp CV language, not prose about what the company wants.
|
||||||
|
- Keep the output directly usable as a CV.
|
||||||
|
- If rewriting the whole CV, output a complete CV with sensible headings and bullets.
|
||||||
|
- If rewriting only one section, return only that rewritten section.
|
||||||
|
- Keep bullets concrete and concise.
|
||||||
|
- If a fact is not present in the source CV, omit it.
|
||||||
|
|
||||||
|
Preferred whole-CV structure when the source supports it:
|
||||||
|
# Contact
|
||||||
|
# Professional Summary
|
||||||
|
# Work Experience
|
||||||
|
# Education
|
||||||
|
# Skills
|
||||||
|
# Certifications
|
||||||
|
# Projects
|
||||||
|
# Languages
|
||||||
|
# Interests
|
||||||
|
|
||||||
|
Instruction:
|
||||||
|
{req.instruction.strip()}
|
||||||
|
|
||||||
|
Candidate source CV:
|
||||||
|
{req.text.strip()}
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
rewritten = _ollama_generate_text(prompt).strip()
|
||||||
|
if not rewritten:
|
||||||
|
raise HTTPException(status_code=502, detail="Ollama returned an empty rewrite.")
|
||||||
|
|
||||||
|
return {"rewritten_text": rewritten}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/summarize")
|
@app.post("/summarize")
|
||||||
async def summarize(req: SummarizeRequest):
|
async def summarize(req: SummarizeRequest):
|
||||||
if req.min_length >= req.max_length:
|
if req.min_length >= req.max_length:
|
||||||
|
|||||||
@@ -76,6 +76,24 @@ def test_health_reports_ollama_unreachable_when_configured_but_not_available(mon
|
|||||||
assert payload["ollama_model_available"] is False
|
assert payload["ollama_model_available"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_rewrite_cv_returns_plain_rewritten_text(monkeypatch):
|
||||||
|
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b")
|
||||||
|
monkeypatch.setattr(module, "_ollama_generate_text", lambda prompt: "# Professional Summary\nBuilt resilient backend systems.\n\n# Skills\n- C#\n- .NET")
|
||||||
|
client = TestClient(module.app)
|
||||||
|
|
||||||
|
response = client.post("/cv/rewrite", json={
|
||||||
|
"instruction": "Rewrite this CV into a cleaner master CV.",
|
||||||
|
"text": "Professional Summary\nBuilt backend systems.",
|
||||||
|
"max_length": 220,
|
||||||
|
"min_length": 80,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["rewritten_text"].startswith("# Professional Summary")
|
||||||
|
assert "Role summary:" not in payload["rewritten_text"]
|
||||||
|
|
||||||
|
|
||||||
def test_classify_block_returns_structured_json(monkeypatch):
|
def test_classify_block_returns_structured_json(monkeypatch):
|
||||||
module = load_app_module(monkeypatch)
|
module = load_app_module(monkeypatch)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user