feat: add cv benchmark workflow and admin visibility
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
@@ -29,11 +30,19 @@ public sealed class CvCorpusHarnessTests
|
||||
|| path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(8)
|
||||
.ToList();
|
||||
|
||||
if (files.Count == 0) return;
|
||||
|
||||
var outputRoot = ResolveOutputRoot();
|
||||
var outputsDir = Path.Combine(outputRoot, "outputs");
|
||||
var candidateFixturesDir = Path.Combine(outputRoot, "candidate-fixtures");
|
||||
var approvedFixturesDir = ResolveApprovedFixturesRoot(outputRoot);
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
Directory.CreateDirectory(outputsDir);
|
||||
Directory.CreateDirectory(candidateFixturesDir);
|
||||
Directory.CreateDirectory(approvedFixturesDir);
|
||||
|
||||
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "seed" };
|
||||
var userManager = TestHostFactory.CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
@@ -44,8 +53,8 @@ public sealed class CvCorpusHarnessTests
|
||||
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Reconstruct this CV text extracted from a PDF", StringComparison.Ordinal)), It.IsAny<string>(), 2800, 900)).ReturnsAsync((string _, string text, int _, int __) => text);
|
||||
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var paths = CreatePaths();
|
||||
var controller = new ProfileCvController(userManager.Object, aiService.Object, db, paths, NoOpCvAiClassifier.Instance)
|
||||
var paths = CreatePaths(outputRoot);
|
||||
var controller = new ProfileCvController(userManager.Object, aiService.Object, db, paths, null, NoOpCvAiClassifier.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
@@ -55,7 +64,7 @@ public sealed class CvCorpusHarnessTests
|
||||
Assert.NotNull(extractMethod);
|
||||
Assert.NotNull(buildMethod);
|
||||
|
||||
var report = new List<object>();
|
||||
var entries = new List<CvBenchmarkEntry>();
|
||||
foreach (var path in files)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
@@ -72,31 +81,212 @@ public sealed class CvCorpusHarnessTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||
|
||||
var buildTask = (Task<StructuredCvProfile>)buildMethod!.Invoke(controller, new object[] { text, CancellationToken.None })!;
|
||||
var structured = await buildTask;
|
||||
var structured = StructuredCvProfileJson.Normalize(await buildTask);
|
||||
Assert.NotNull(structured);
|
||||
|
||||
report.Add(new
|
||||
var slug = Slugify(fileName);
|
||||
var normalizedJson = StructuredCvProfileJson.Serialize(structured);
|
||||
var outputPath = Path.Combine(outputsDir, $"{slug}.json");
|
||||
await File.WriteAllTextAsync(outputPath, PrettyJson(normalizedJson));
|
||||
|
||||
var approvedPath = Path.Combine(approvedFixturesDir, $"{slug}.json");
|
||||
var candidateFixturePath = Path.Combine(candidateFixturesDir, $"{slug}.json");
|
||||
string? diffSummary = null;
|
||||
var approvedExists = File.Exists(approvedPath);
|
||||
if (approvedExists)
|
||||
{
|
||||
file = fileName,
|
||||
characters = text.Length,
|
||||
contactLocation = structured.Contact.Location,
|
||||
firstJob = structured.Jobs.FirstOrDefault()?.Title,
|
||||
firstJobLocation = structured.Jobs.FirstOrDefault()?.Location,
|
||||
firstEducation = structured.Education.FirstOrDefault()?.Qualification,
|
||||
firstEducationLocation = structured.Education.FirstOrDefault()?.Location,
|
||||
suspiciousLocations = structured.Jobs.Select(job => job.Location)
|
||||
var approvedJson = await File.ReadAllTextAsync(approvedPath);
|
||||
diffSummary = SummarizeDiff(approvedJson, normalizedJson);
|
||||
}
|
||||
else
|
||||
{
|
||||
await File.WriteAllTextAsync(candidateFixturePath, PrettyJson(normalizedJson));
|
||||
diffSummary = "No approved fixture yet — candidate fixture written.";
|
||||
}
|
||||
|
||||
entries.Add(new CvBenchmarkEntry(
|
||||
FileName: fileName,
|
||||
Slug: slug,
|
||||
Extension: extension,
|
||||
Characters: text.Length,
|
||||
OutputPath: outputPath,
|
||||
ApprovedFixturePath: approvedExists ? approvedPath : null,
|
||||
CandidateFixturePath: approvedExists ? null : candidateFixturePath,
|
||||
ContactLocation: structured.Contact.Location,
|
||||
FirstJob: structured.Jobs.FirstOrDefault()?.Title,
|
||||
FirstJobLocation: structured.Jobs.FirstOrDefault()?.Location,
|
||||
FirstEducation: structured.Education.FirstOrDefault()?.Qualification,
|
||||
FirstEducationLocation: structured.Education.FirstOrDefault()?.Location,
|
||||
QualificationLevels: structured.Education.Select(x => x.QualificationLevel).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList(),
|
||||
SuspiciousLocations: structured.Jobs.Select(job => job.Location)
|
||||
.Concat(structured.Education.Select(education => education.Location))
|
||||
.Append(structured.Contact.Location)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Cast<string>()
|
||||
.Where(LooksSuspiciousLocation)
|
||||
.ToList()
|
||||
});
|
||||
.ToList(),
|
||||
CoverageScore: ComputeCoverageScore(structured),
|
||||
ConfidenceScore: ComputeConfidenceScore(structured),
|
||||
ConsistencyScore: ComputeConsistencyScore(structured),
|
||||
DiffSummary: diffSummary
|
||||
));
|
||||
}
|
||||
|
||||
var reportPath = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{DateTime.UtcNow:yyyyMMddHHmmss}.json");
|
||||
await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
|
||||
var summary = new CvBenchmarkSummary(
|
||||
CorpusRoot,
|
||||
outputRoot,
|
||||
DateTimeOffset.UtcNow,
|
||||
entries.Count,
|
||||
Math.Round(entries.Average(x => x.CoverageScore), 3),
|
||||
Math.Round(entries.Average(x => x.ConfidenceScore), 3),
|
||||
Math.Round(entries.Average(x => x.ConsistencyScore), 3),
|
||||
entries.Count(x => x.SuspiciousLocations.Count > 0),
|
||||
entries.Count(x => x.ApprovedFixturePath is null),
|
||||
entries
|
||||
);
|
||||
|
||||
Assert.True(report.Count > 0);
|
||||
var indexPath = Path.Combine(outputRoot, "index.json");
|
||||
var reportPath = Path.Combine(outputRoot, "report.md");
|
||||
await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true }));
|
||||
await File.WriteAllTextAsync(reportPath, RenderMarkdownReport(summary));
|
||||
|
||||
Assert.True(entries.Count > 0);
|
||||
}
|
||||
|
||||
private sealed record CvBenchmarkEntry(
|
||||
string FileName,
|
||||
string Slug,
|
||||
string Extension,
|
||||
int Characters,
|
||||
string OutputPath,
|
||||
string? ApprovedFixturePath,
|
||||
string? CandidateFixturePath,
|
||||
string? ContactLocation,
|
||||
string? FirstJob,
|
||||
string? FirstJobLocation,
|
||||
string? FirstEducation,
|
||||
string? FirstEducationLocation,
|
||||
List<string> QualificationLevels,
|
||||
List<string> SuspiciousLocations,
|
||||
double CoverageScore,
|
||||
double ConfidenceScore,
|
||||
double ConsistencyScore,
|
||||
string? DiffSummary);
|
||||
|
||||
private sealed record CvBenchmarkSummary(
|
||||
string CorpusRoot,
|
||||
string OutputRoot,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
int TotalFiles,
|
||||
double AverageCoverage,
|
||||
double AverageConfidence,
|
||||
double AverageConsistency,
|
||||
int FilesWithSuspiciousLocations,
|
||||
int MissingApprovedFixtures,
|
||||
List<CvBenchmarkEntry> Entries);
|
||||
|
||||
private static string ResolveOutputRoot()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_OUTPUT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured.Trim();
|
||||
return Path.Combine(Path.GetTempPath(), "jobtracker-cv-benchmark", DateTime.UtcNow.ToString("yyyyMMddHHmmss"));
|
||||
}
|
||||
|
||||
private static string ResolveApprovedFixturesRoot(string outputRoot)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_APPROVED_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured.Trim();
|
||||
return Path.Combine(outputRoot, "approved-fixtures");
|
||||
}
|
||||
|
||||
private static string PrettyJson(string normalizedJson)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(normalizedJson);
|
||||
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
private static string SummarizeDiff(string approvedJson, string actualJson)
|
||||
{
|
||||
if (JsonDocument.Parse(approvedJson).RootElement.ToString() == JsonDocument.Parse(actualJson).RootElement.ToString())
|
||||
{
|
||||
return "Matches approved fixture.";
|
||||
}
|
||||
|
||||
var approvedHash = Hash(approvedJson);
|
||||
var actualHash = Hash(actualJson);
|
||||
return $"Fixture differs (approved {approvedHash[..8]}, actual {actualHash[..8]}).";
|
||||
}
|
||||
|
||||
private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
|
||||
private static double ComputeCoverageScore(StructuredCvProfile structured)
|
||||
{
|
||||
var signals = new[]
|
||||
{
|
||||
!string.IsNullOrWhiteSpace(structured.Contact.FullName),
|
||||
!string.IsNullOrWhiteSpace(structured.Contact.Email),
|
||||
!string.IsNullOrWhiteSpace(structured.Contact.Location),
|
||||
structured.Summary.Count > 0,
|
||||
structured.Skills.Count > 0,
|
||||
structured.Jobs.Count > 0,
|
||||
structured.Education.Count > 0,
|
||||
structured.Certifications.Count > 0 || structured.Projects.Count > 0 || structured.OtherSections.Count > 0,
|
||||
};
|
||||
return signals.Count(x => x) / (double)signals.Length;
|
||||
}
|
||||
|
||||
private static double ComputeConfidenceScore(StructuredCvProfile structured)
|
||||
{
|
||||
var confidences = structured.Metadata.Fields.Values.Select(x => x.Confidence).Where(x => x.HasValue).Select(x => x!.Value).ToList();
|
||||
return confidences.Count == 0 ? 0.55 : Math.Clamp(confidences.Average(), 0, 1);
|
||||
}
|
||||
|
||||
private static double ComputeConsistencyScore(StructuredCvProfile structured)
|
||||
{
|
||||
var penalties = 0;
|
||||
penalties += structured.Jobs.Count(job => LooksSuspiciousLocation(job.Location));
|
||||
penalties += structured.Education.Count(education => LooksSuspiciousLocation(education.Location));
|
||||
penalties += LooksSuspiciousLocation(structured.Contact.Location) ? 1 : 0;
|
||||
penalties += structured.Education.Count(education => string.IsNullOrWhiteSpace(education.QualificationLevel) && !string.IsNullOrWhiteSpace(education.Qualification));
|
||||
return Math.Max(0, 1 - (penalties * 0.12));
|
||||
}
|
||||
|
||||
private static string RenderMarkdownReport(CvBenchmarkSummary summary)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
"# CV benchmark report",
|
||||
string.Empty,
|
||||
$"- Generated: {summary.GeneratedAtUtc:O}",
|
||||
$"- Corpus root: `{summary.CorpusRoot}`",
|
||||
$"- Output root: `{summary.OutputRoot}`",
|
||||
$"- Files: {summary.TotalFiles}",
|
||||
$"- Average coverage: {summary.AverageCoverage:P0}",
|
||||
$"- Average confidence: {summary.AverageConfidence:P0}",
|
||||
$"- Average consistency: {summary.AverageConsistency:P0}",
|
||||
$"- Files with suspicious locations: {summary.FilesWithSuspiciousLocations}",
|
||||
$"- Missing approved fixtures: {summary.MissingApprovedFixtures}",
|
||||
string.Empty,
|
||||
"| File | Coverage | Confidence | Consistency | Suspicious locations | Fixture |",
|
||||
"|---|---:|---:|---:|---:|---|",
|
||||
};
|
||||
|
||||
lines.AddRange(summary.Entries.Select(entry =>
|
||||
$"| {entry.FileName} | {entry.CoverageScore:P0} | {entry.ConfidenceScore:P0} | {entry.ConsistencyScore:P0} | {entry.SuspiciousLocations.Count} | {entry.DiffSummary} |"));
|
||||
|
||||
lines.Add(string.Empty);
|
||||
lines.Add("## Notes");
|
||||
lines.Add("- `outputs/*.json` contains the latest normalized parser output for each CV.");
|
||||
lines.Add("- `candidate-fixtures/*.json` is created when no approved fixture exists yet.");
|
||||
lines.Add("- To build a regression baseline, review a candidate fixture and copy it into the approved-fixtures directory used by the runner.");
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private static string Slugify(string value)
|
||||
{
|
||||
var cleaned = new string((value ?? string.Empty).ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray());
|
||||
while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal);
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
|
||||
private static bool LooksSuspiciousLocation(string? value)
|
||||
@@ -119,7 +309,7 @@ public sealed class CvCorpusHarnessTests
|
||||
};
|
||||
}
|
||||
|
||||
private static AppPaths CreatePaths()
|
||||
private static AppPaths CreatePaths(string outputRoot)
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
@@ -128,7 +318,8 @@ public sealed class CvCorpusHarnessTests
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Data:Root"] = tempRoot,
|
||||
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts")
|
||||
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts"),
|
||||
["Data:CvBenchmarksRoot"] = outputRoot,
|
||||
})
|
||||
.Build();
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class JobApplicationsControllerTests
|
||||
Assert.NotNull(type);
|
||||
|
||||
var ctor = type!.GetConstructors().Single();
|
||||
var parameters = ctor.GetParameters().Select(x => x.Name).ToArray();
|
||||
var parameters = ctor.GetParameters().Select(x => x.Name).Where(x => x is not null).Select(x => x!).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("coverLetterText", parameters);
|
||||
Assert.Contains("notes", parameters);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class AdminSystemController : ControllerBase
|
||||
public sealed record DatabaseStatusDto(string Provider, bool LooksConfigured, bool CanConnect, string? Target, bool UsesFileStorage, string? Warning);
|
||||
public sealed record RuntimeStatusDto(string Framework, string OSDescription, string ProcessArchitecture, string? MachineName);
|
||||
public sealed record AuthStatusDto(bool Required, bool HasJwtKey, bool GoogleConfigured, bool GmailConfigured);
|
||||
public sealed record CvBenchmarkStatusDto(string? IndexJson, string? ReportMarkdown, string RootPath, DateTimeOffset? LastUpdatedAtUtc);
|
||||
public sealed record SystemStatusDto(
|
||||
string Environment,
|
||||
string ContentRoot,
|
||||
@@ -86,6 +87,22 @@ public sealed class AdminSystemController : ControllerBase
|
||||
return Ok(await _emailSettings.UpdateAsync(request, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("cv-benchmark")]
|
||||
public async Task<ActionResult<CvBenchmarkStatusDto>> GetCvBenchmarkStatus(CancellationToken cancellationToken)
|
||||
{
|
||||
var indexPath = Path.Combine(_paths.CvBenchmarksRoot, "index.json");
|
||||
var reportPath = Path.Combine(_paths.CvBenchmarksRoot, "report.md");
|
||||
var indexJson = System.IO.File.Exists(indexPath) ? await System.IO.File.ReadAllTextAsync(indexPath, cancellationToken) : null;
|
||||
var reportMarkdown = System.IO.File.Exists(reportPath) ? await System.IO.File.ReadAllTextAsync(reportPath, cancellationToken) : null;
|
||||
var lastUpdated = new[]
|
||||
{
|
||||
System.IO.File.Exists(indexPath) ? System.IO.File.GetLastWriteTimeUtc(indexPath) : (DateTime?)null,
|
||||
System.IO.File.Exists(reportPath) ? System.IO.File.GetLastWriteTimeUtc(reportPath) : (DateTime?)null,
|
||||
}.Where(value => value.HasValue).Select(value => value!.Value).DefaultIfEmpty().Max();
|
||||
|
||||
return Ok(new CvBenchmarkStatusDto(indexJson, reportMarkdown, _paths.CvBenchmarksRoot, lastUpdated == default ? null : new DateTimeOffset(DateTime.SpecifyKind(lastUpdated, DateTimeKind.Utc))));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -128,6 +145,10 @@ public sealed class AdminSystemController : ControllerBase
|
||||
OllamaReachable: null,
|
||||
OllamaModel: null,
|
||||
OllamaModelAvailable: null,
|
||||
OllamaVersion: null,
|
||||
OllamaInstalledModels: Array.Empty<string>(),
|
||||
OllamaLoadedModels: Array.Empty<string>(),
|
||||
OllamaLoadedCount: 0,
|
||||
HealthLatencyMs: null,
|
||||
ProbeLatencyMs: null,
|
||||
LastProbeAt: null,
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace JobTrackerApi.Services
|
||||
public string AttachmentsRoot { get; }
|
||||
public string CvArtifactsRoot { get; }
|
||||
public string CvExportsRoot { get; }
|
||||
public string CvBenchmarksRoot { get; }
|
||||
|
||||
public AppPaths(IConfiguration cfg, IHostEnvironment env)
|
||||
{
|
||||
@@ -39,6 +40,13 @@ namespace JobTrackerApi.Services
|
||||
|
||||
Directory.CreateDirectory(cvExportsRoot);
|
||||
CvExportsRoot = cvExportsRoot;
|
||||
|
||||
var cvBenchmarksRoot = (cfg["Data:CvBenchmarksRoot"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(cvBenchmarksRoot)) cvBenchmarksRoot = Path.Combine(DataRoot, "CvBenchmarks");
|
||||
if (!Path.IsPathRooted(cvBenchmarksRoot)) cvBenchmarksRoot = Path.Combine(env.ContentRootPath, cvBenchmarksRoot);
|
||||
|
||||
Directory.CreateDirectory(cvBenchmarksRoot);
|
||||
CvBenchmarksRoot = cvBenchmarksRoot;
|
||||
}
|
||||
|
||||
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
|
||||
|
||||
@@ -25,6 +25,10 @@ namespace JobTrackerApi.Services
|
||||
bool? OllamaReachable,
|
||||
string? OllamaModel,
|
||||
bool? OllamaModelAvailable,
|
||||
string? OllamaVersion,
|
||||
IReadOnlyList<string>? OllamaInstalledModels,
|
||||
IReadOnlyList<string>? OllamaLoadedModels,
|
||||
int? OllamaLoadedCount,
|
||||
double? HealthLatencyMs,
|
||||
double? ProbeLatencyMs,
|
||||
DateTimeOffset? LastProbeAt,
|
||||
@@ -66,6 +70,7 @@ namespace JobTrackerApi.Services
|
||||
|
||||
public interface ISummarizerService : IAiService
|
||||
{
|
||||
new Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40);
|
||||
}
|
||||
|
||||
public class SummarizerService : ISummarizerService
|
||||
@@ -318,6 +323,10 @@ namespace JobTrackerApi.Services
|
||||
bool? ollamaReachable = null;
|
||||
string? ollamaModel = null;
|
||||
bool? ollamaModelAvailable = null;
|
||||
string? ollamaVersion = null;
|
||||
List<string>? ollamaInstalledModels = null;
|
||||
List<string>? ollamaLoadedModels = null;
|
||||
int? ollamaLoadedCount = null;
|
||||
double? healthLatencyMs = null;
|
||||
var healthy = false;
|
||||
string? healthError = null;
|
||||
@@ -344,6 +353,16 @@ namespace JobTrackerApi.Services
|
||||
if (doc.RootElement.TryGetProperty("ollama_reachable", out var ollamaReachableEl) && ollamaReachableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaReachable = ollamaReachableEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("ollama_model", out var ollamaModelEl)) ollamaModel = ollamaModelEl.GetString();
|
||||
if (doc.RootElement.TryGetProperty("ollama_model_available", out var ollamaModelAvailableEl) && ollamaModelAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaModelAvailable = ollamaModelAvailableEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("ollama_version", out var ollamaVersionEl)) ollamaVersion = ollamaVersionEl.GetString();
|
||||
if (doc.RootElement.TryGetProperty("ollama_installed_models", out var ollamaInstalledModelsEl) && ollamaInstalledModelsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
ollamaInstalledModels = ollamaInstalledModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList();
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("ollama_loaded_models", out var ollamaLoadedModelsEl) && ollamaLoadedModelsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
ollamaLoadedModels = ollamaLoadedModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList();
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("ollama_loaded_count", out var ollamaLoadedCountEl) && ollamaLoadedCountEl.ValueKind == JsonValueKind.Number) ollamaLoadedCount = ollamaLoadedCountEl.GetInt32();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -406,6 +425,10 @@ namespace JobTrackerApi.Services
|
||||
OllamaReachable: ollamaReachable,
|
||||
OllamaModel: ollamaModel,
|
||||
OllamaModelAvailable: ollamaModelAvailable,
|
||||
OllamaVersion: ollamaVersion,
|
||||
OllamaInstalledModels: ollamaInstalledModels,
|
||||
OllamaLoadedModels: ollamaLoadedModels,
|
||||
OllamaLoadedCount: ollamaLoadedCount,
|
||||
HealthLatencyMs: healthLatencyMs,
|
||||
ProbeLatencyMs: probeLatencyMs,
|
||||
LastProbeAt: lastProbeAt,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
# CV builder, parser, benchmark, and Ollama admin integration
|
||||
|
||||
## What changed
|
||||
|
||||
This branch upgrades the Profile CV flow from a text-only rewrite helper into a template-driven CV builder backed by the server-side renderer/PDF pipeline, strengthens CV normalization around location and qualification handling, adds a repeatable local corpus benchmark workflow, and expands the admin system page with richer Ollama visibility.
|
||||
|
||||
## Profile CV builder
|
||||
|
||||
### New backend capabilities
|
||||
|
||||
`JobTrackerApi/Controllers/ProfileCvController.cs`
|
||||
|
||||
- Hardened `POST /api/profile-cv/rewrite-section`
|
||||
- accepts flexible `jobApplicationId` payloads (number or blank string)
|
||||
- uses richer saved-job context for tailoring
|
||||
- logs empty AI responses with useful context
|
||||
- Added `GET /api/profile-cv/templates`
|
||||
- Added `POST /api/profile-cv/rewrite-preview`
|
||||
- rewrites either the whole CV or one selected section
|
||||
- rebuilds structured CV from the rewritten full text
|
||||
- maps the result into the shared template renderer
|
||||
- returns rendered HTML, file name, rewritten text, and full replacement text
|
||||
- Added `POST /api/profile-cv/export-pdf`
|
||||
- uses the same rendered HTML and the shared Playwright exporter
|
||||
|
||||
### Frontend flow
|
||||
|
||||
`job-tracker-ui/src/pages/ProfilePage.tsx`
|
||||
|
||||
- Replaced the old rewrite draft box with a template-driven builder section.
|
||||
- Users can:
|
||||
- choose from 6 templates
|
||||
- optionally target one section
|
||||
- target by free-text role or saved job
|
||||
- inspect the rewritten content
|
||||
- inspect the actual rendered preview
|
||||
- download a PDF
|
||||
- replace the master CV with the rebuilt full-text result
|
||||
|
||||
## Templates
|
||||
|
||||
Shared renderer: `JobTrackerApi/Services/CvTemplateRenderer.cs`
|
||||
|
||||
Available templates:
|
||||
- `ats-minimal`
|
||||
- `harvard`
|
||||
- `auckland`
|
||||
- `edinburgh`
|
||||
- `monarch`
|
||||
- `fjord`
|
||||
|
||||
### Adding a new template
|
||||
|
||||
1. Add the new template id to `NormalizeTemplateId()` in:
|
||||
- `JobTrackerApi/Services/CvTemplateRenderer.cs`
|
||||
- `JobTrackerApi/Controllers/ProfileCvController.cs`
|
||||
2. Add a render branch in `CvTemplateRenderer.Render()`.
|
||||
3. Add a descriptor to `GetCvTemplateDescriptors()`.
|
||||
4. Add the matching card entry in `job-tracker-ui/src/pages/ProfilePage.tsx` if you want a custom preview card.
|
||||
|
||||
## PDF generation
|
||||
|
||||
The master CV builder now reuses the existing server-side pipeline:
|
||||
|
||||
1. rewrite full text / section
|
||||
2. rebuild structured CV
|
||||
3. map to `TailoredCvDocument`
|
||||
4. render HTML via `ICvTemplateRenderer`
|
||||
5. export PDF via `ICvPdfExporter` / Playwright
|
||||
|
||||
This keeps PDF output visually aligned with the selected template and avoids a separate client-only print implementation.
|
||||
|
||||
## Parser and structured CV changes
|
||||
|
||||
### Shared schema
|
||||
|
||||
`Models/StructuredCvProfile.cs`
|
||||
|
||||
Added:
|
||||
- `education[].qualificationLevel`
|
||||
- top-level `certifications[]`
|
||||
- top-level `projects[]`
|
||||
|
||||
`qualification` remains the original preserved text.
|
||||
|
||||
### Normalization improvements
|
||||
|
||||
`Models/StructuredCvProfileJson.cs`
|
||||
|
||||
- tighter location sanitization to avoid skill or role spillover into location fields
|
||||
- qualification level normalization to one of:
|
||||
- `Secondary`
|
||||
- `Diploma/Certificate`
|
||||
- `Bachelor`
|
||||
- `Master`
|
||||
- `PhD`
|
||||
- `Other`
|
||||
- first-class normalization for certifications and projects
|
||||
- section reconstruction now includes certifications and projects
|
||||
|
||||
### Extraction prompt improvements
|
||||
|
||||
`JobTrackerApi/Controllers/ProfileCvController.cs`
|
||||
|
||||
The LLM extraction prompt now explicitly asks for:
|
||||
- qualification level enum
|
||||
- certifications
|
||||
- projects
|
||||
- strict location separation rules
|
||||
- preservation of original qualification text
|
||||
|
||||
## Benchmark workflow
|
||||
|
||||
### Runner
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
./scripts/run-cv-benchmark.sh
|
||||
```
|
||||
|
||||
Optional overrides:
|
||||
|
||||
```bash
|
||||
CV_BENCHMARK_OUTPUT_DIR=/absolute/output/path \
|
||||
CV_BENCHMARK_APPROVED_DIR=/absolute/approved/fixtures/path \
|
||||
./scripts/run-cv-benchmark.sh
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
The runner scans:
|
||||
|
||||
- `/home/pi/cvs`
|
||||
|
||||
Supported corpus file types:
|
||||
- PDF
|
||||
- DOCX
|
||||
- TXT
|
||||
- MD
|
||||
|
||||
### Outputs
|
||||
|
||||
The runner writes:
|
||||
|
||||
- `index.json` — machine-readable summary
|
||||
- `report.md` — markdown overview
|
||||
- `outputs/*.json` — latest normalized structured output per CV
|
||||
- `candidate-fixtures/*.json` — created when no approved fixture exists yet
|
||||
|
||||
Approved fixtures are local by design and should be reviewed manually before being promoted into the approved fixture path you use for regression comparisons.
|
||||
|
||||
### Admin review
|
||||
|
||||
`GET /api/admin/system/cv-benchmark`
|
||||
|
||||
The admin system page surfaces:
|
||||
- benchmark root path
|
||||
- last benchmark update time
|
||||
- latest markdown summary
|
||||
|
||||
## Ollama admin visibility
|
||||
|
||||
### Python health endpoint
|
||||
|
||||
`tools/summarizer/app.py`
|
||||
|
||||
`GET /health` now returns additional Ollama metadata when configured/reachable:
|
||||
- `ollama_version`
|
||||
- `ollama_installed_models`
|
||||
- `ollama_loaded_models`
|
||||
- `ollama_loaded_count`
|
||||
|
||||
### Backend propagation
|
||||
|
||||
`JobTrackerApi/Services/SummarizerService.cs`
|
||||
|
||||
The backend metrics shape now carries those fields through to admin consumers.
|
||||
|
||||
### Admin UI
|
||||
|
||||
`job-tracker-ui/src/pages/AdminSystemPage.tsx`
|
||||
|
||||
The system page now shows:
|
||||
- Ollama version
|
||||
- loaded model count
|
||||
- installed model chips
|
||||
- loaded model chips
|
||||
- benchmark summary panel
|
||||
|
||||
## Verification used on this branch
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter ProfileCvControllerTests
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter "ProfileCvControllerTests|AuthAndSystemControllerTests|JobApplicationsApplicationPackageTests"
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter CvCorpusHarnessTests
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/profile-page.test.tsx
|
||||
cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/admin-system-page.test.tsx
|
||||
cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/profile-page.test.tsx src/admin-system-page.test.tsx src/job-details-generated-drafts.test.tsx
|
||||
```
|
||||
|
||||
### Benchmark runner
|
||||
|
||||
```bash
|
||||
CV_BENCHMARK_OUTPUT_DIR="$(pwd)/tmp/cv-benchmarks/latest" \
|
||||
CV_BENCHMARK_APPROVED_DIR="$(pwd)/tmp/cv-benchmarks/approved" \
|
||||
./scripts/run-cv-benchmark.sh
|
||||
```
|
||||
|
||||
### Python service tests
|
||||
|
||||
The summarizer Python unit tests were updated for the new health payload, but this machine currently lacks `pip` / `venv` support (`python3 -m venv` fails because `python3.12-venv` is not installed), so test execution is environment-blocked here. Once Python packaging is available, run:
|
||||
|
||||
```bash
|
||||
cd tools/summarizer
|
||||
python3 -m pytest -q tests/test_app.py
|
||||
```
|
||||
@@ -43,6 +43,14 @@ describe('AdminSystemPage', () => {
|
||||
gpuName: null,
|
||||
ocrAvailable: true,
|
||||
ocrLanguages: 'eng',
|
||||
ollamaConfigured: true,
|
||||
ollamaReachable: true,
|
||||
ollamaModel: 'qwen2.5:7b',
|
||||
ollamaModelAvailable: true,
|
||||
ollamaVersion: '0.7.0',
|
||||
ollamaInstalledModels: ['qwen2.5:7b', 'nomic-embed-text'],
|
||||
ollamaLoadedModels: ['qwen2.5:7b'],
|
||||
ollamaLoadedCount: 1,
|
||||
healthLatencyMs: 12.4,
|
||||
probeLatencyMs: 25.8,
|
||||
lastProbeAt: '2026-03-23T10:00:00Z',
|
||||
@@ -82,6 +90,15 @@ describe('AdminSystemPage', () => {
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === '/admin/system/cv-benchmark') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
rootPath: '/data/CvBenchmarks/latest',
|
||||
lastUpdatedAtUtc: '2026-03-23T10:10:00Z',
|
||||
reportMarkdown: '# CV benchmark report\n\n- Files: 4',
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.put.mockResolvedValue({
|
||||
@@ -118,6 +135,9 @@ describe('AdminSystemPage', () => {
|
||||
expect(screen.getByText(/25.8 ms probe/i)).toBeTruthy();
|
||||
expect(screen.getByText('OCR eng')).toBeTruthy();
|
||||
expect(screen.getAllByText(/ollama configured/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/ollama version/i)).toBeTruthy();
|
||||
expect(screen.getByText(/model · qwen2.5:7b/i)).toBeTruthy();
|
||||
expect(screen.getByText(/cv benchmark review/i)).toBeTruthy();
|
||||
expect(screen.getByText('OCR avg latency')).toBeTruthy();
|
||||
expect(screen.getByText('88.4 ms')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -26,6 +26,14 @@ type AiServiceMetrics = {
|
||||
gpuName?: string | null;
|
||||
ocrAvailable?: boolean | null;
|
||||
ocrLanguages?: string | null;
|
||||
ollamaConfigured?: boolean | null;
|
||||
ollamaReachable?: boolean | null;
|
||||
ollamaModel?: string | null;
|
||||
ollamaModelAvailable?: boolean | null;
|
||||
ollamaVersion?: string | null;
|
||||
ollamaInstalledModels?: string[] | null;
|
||||
ollamaLoadedModels?: string[] | null;
|
||||
ollamaLoadedCount?: number | null;
|
||||
healthLatencyMs?: number | null;
|
||||
probeLatencyMs?: number | null;
|
||||
lastProbeAt?: string | null;
|
||||
@@ -60,6 +68,13 @@ type EditableEmailSettings = {
|
||||
hasPassword: boolean;
|
||||
};
|
||||
|
||||
type CvBenchmarkStatus = {
|
||||
indexJson?: string | null;
|
||||
reportMarkdown?: string | null;
|
||||
rootPath: string;
|
||||
lastUpdatedAtUtc?: string | null;
|
||||
};
|
||||
|
||||
type SystemStatus = {
|
||||
environment: string;
|
||||
contentRoot: string;
|
||||
@@ -141,6 +156,7 @@ export default function AdminSystemPage() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [emailSettings, setEmailSettings] = useState<EditableEmailSettings | null>(null);
|
||||
const [benchmarkStatus, setBenchmarkStatus] = useState<CvBenchmarkStatus | null>(null);
|
||||
const [smtpPassword, setSmtpPassword] = useState("");
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -156,18 +172,21 @@ export default function AdminSystemPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [statusRes, emailRes] = await Promise.all([
|
||||
const [statusRes, emailRes, benchmarkRes] = await Promise.all([
|
||||
api.get<SystemStatus>("/admin/system"),
|
||||
api.get<EditableEmailSettings>("/admin/system/email-settings"),
|
||||
api.get<CvBenchmarkStatus>("/admin/system/cv-benchmark").catch(() => ({ data: null } as any)),
|
||||
]);
|
||||
setStatus(statusRes.data);
|
||||
setEmailSettings(emailRes.data);
|
||||
setBenchmarkStatus(benchmarkRes.data ?? null);
|
||||
setSmtpPassword("");
|
||||
setClearPassword(false);
|
||||
} catch (e: any) {
|
||||
setError(getApiErrorMessage(e, "Failed to load system status."));
|
||||
setStatus(null);
|
||||
setEmailSettings(null);
|
||||
setBenchmarkStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -367,6 +386,8 @@ export default function AdminSystemPage() {
|
||||
<DetailRow label={t("adminSystemOllamaReachable")} value={status?.ai.ollamaReachable ? t("yes") : t("noWord")} />
|
||||
<DetailRow label={t("adminSystemOllamaModel")} value={status?.ai.ollamaModel || "-"} />
|
||||
<DetailRow label={t("adminSystemOllamaModelAvailable")} value={status?.ai.ollamaModelAvailable ? t("yes") : t("noWord")} />
|
||||
<DetailRow label="Ollama version" value={status?.ai.ollamaVersion || "-"} />
|
||||
<DetailRow label="Loaded models" value={status?.ai.ollamaLoadedCount ?? 0} />
|
||||
<DetailRow label={t("adminSystemHealthLatency")} value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} />
|
||||
<DetailRow label={t("adminSystemProbeLatency")} value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} />
|
||||
<DetailRow label={t("adminSystemLastProbe")} value={formatDate(status?.ai.lastProbeAt)} />
|
||||
@@ -395,6 +416,25 @@ export default function AdminSystemPage() {
|
||||
<Chip label={status?.auth.gmailConfigured ? t("adminSystemGmailReady") : t("adminSystemGmailIncomplete")} variant="outlined" size="small" />
|
||||
<Chip label={status?.ai.gpuAvailable ? t("adminSystemGpuVisible") : t("adminSystemCpuMode")} color={status?.ai.gpuAvailable ? "success" : "default"} size="small" />
|
||||
<Chip label={status?.ai.ocrAvailable ? `OCR ${status.ai.ocrLanguages || "enabled"}` : t("adminSystemOcrUnavailable")} variant="outlined" size="small" />
|
||||
{(status?.ai.ollamaInstalledModels ?? []).slice(0, 4).map((model) => (
|
||||
<Chip key={model} label={`Model · ${model}`} variant="outlined" size="small" />
|
||||
))}
|
||||
{(status?.ai.ollamaLoadedModels ?? []).slice(0, 3).map((model) => (
|
||||
<Chip key={`loaded-${model}`} label={`Loaded · ${model}`} color="primary" variant="outlined" size="small" />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>CV benchmark review</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
<DetailRow label="Benchmark root" value={benchmarkStatus?.rootPath || "-"} />
|
||||
<DetailRow label="Last benchmark update" value={formatDate(benchmarkStatus?.lastUpdatedAtUtc)} />
|
||||
</Stack>
|
||||
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider", maxHeight: 260, overflow: "auto" }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", fontFamily: "ui-monospace, SFMono-Regular, monospace" }}>
|
||||
{benchmarkStatus?.reportMarkdown || "Run scripts/run-cv-benchmark.sh to generate the latest corpus report and fixture candidates."}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { JobApplication } from "../types";
|
||||
|
||||
|
||||
type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
||||
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh";
|
||||
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord";
|
||||
|
||||
type ExtractionRun = {
|
||||
id: number;
|
||||
@@ -60,6 +60,18 @@ type RewriteTemplateOption = {
|
||||
sampleBullets: string[];
|
||||
};
|
||||
|
||||
type CvBuilderPreview = {
|
||||
templateId: CvSectionStyle;
|
||||
html: string;
|
||||
suggestedFileName: string;
|
||||
fullText: string;
|
||||
rewrittenText: string;
|
||||
structuredCv: StructuredCvProfile;
|
||||
sectionName?: string | null;
|
||||
targetRole?: string | null;
|
||||
jobApplicationId?: number | null;
|
||||
};
|
||||
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "external";
|
||||
id?: string;
|
||||
@@ -122,6 +134,26 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
|
||||
sampleMeta: "Premium spacing · stronger visual voice",
|
||||
sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."]
|
||||
},
|
||||
{
|
||||
id: "monarch",
|
||||
title: "Monarch",
|
||||
eyebrow: "Executive",
|
||||
accent: "#7c2d12",
|
||||
blurb: "High-contrast premium presentation for leadership-heavy applications.",
|
||||
sampleHeading: "Executive Profile",
|
||||
sampleMeta: "Leadership clarity · premium hierarchy",
|
||||
sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."]
|
||||
},
|
||||
{
|
||||
id: "fjord",
|
||||
title: "Fjord",
|
||||
eyebrow: "Technical",
|
||||
accent: "#0f4c5c",
|
||||
blurb: "Calm, high-density layout for engineering resumes and project-heavy CVs.",
|
||||
sampleHeading: "Projects & Systems",
|
||||
sampleMeta: "Technical depth · practical readability",
|
||||
sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."]
|
||||
},
|
||||
];
|
||||
|
||||
function initialsFrom(values: Array<string | undefined>) {
|
||||
@@ -208,7 +240,7 @@ export default function ProfilePage() {
|
||||
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
|
||||
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
||||
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
|
||||
const [cvSectionDraft, setCvSectionDraft] = useState("");
|
||||
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
|
||||
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
|
||||
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
|
||||
const [parsingCvSections, setParsingCvSections] = useState(false);
|
||||
@@ -263,6 +295,7 @@ export default function ProfilePage() {
|
||||
const latestRun = extractionRuns[0];
|
||||
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
|
||||
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
|
||||
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||
@@ -742,39 +775,22 @@ export default function ProfilePage() {
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.03) 0%, rgba(15,23,42,0) 100%)" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box sx={{ mt: 2, p: 2.25, borderRadius: 4, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.04) 0%, rgba(15,23,42,0) 100%)" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.75 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>CV style rewrite studio</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvSectionToolsHelp")}</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Template-driven CV builder</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", maxWidth: 720 }}>
|
||||
Choose a template, optionally target one section, and tailor the output toward a saved job or free-text role target. The preview below renders the actual PDF layout before you apply it.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip size="small" variant="outlined" label={cvSection || "Whole CV rewrite"} />
|
||||
{selectedRewriteJob ? <Chip size="small" color="primary" variant="outlined" label={`Saved job · ${selectedRewriteJob.jobTitle}`} /> : null}
|
||||
{rewriteReady ? <Chip size="small" color="success" label="Preview ready" /> : null}
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||
onClick={async () => {
|
||||
setRewritingSection(true);
|
||||
try {
|
||||
const res = await api.post<{ text?: string }>("/profile-cv/rewrite-section", {
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
});
|
||||
setCvSectionDraft(res.data?.text ?? "");
|
||||
toast(t("profileCvSectionRewritten"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
|
||||
} finally {
|
||||
setRewritingSection(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rewritingSection ? t("profileCvSectionRewriting") : t("profileCvSectionRewrite")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
|
||||
{REWRITE_TEMPLATES.map((option) => {
|
||||
const selected = option.id === cvSectionStyle;
|
||||
return (
|
||||
@@ -791,25 +807,27 @@ export default function ProfilePage() {
|
||||
}}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 3,
|
||||
borderRadius: 3.5,
|
||||
cursor: "pointer",
|
||||
border: "1px solid",
|
||||
borderColor: selected ? "primary.main" : "divider",
|
||||
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.15)" : "none",
|
||||
backgroundColor: selected ? "rgba(25,118,210,0.06)" : "background.paper",
|
||||
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>
|
||||
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 800 }}>{option.eyebrow}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{option.title}</Typography>
|
||||
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 900, letterSpacing: '0.14em' }}>{option.eyebrow}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={(event) => { event.stopPropagation(); setRewritePreviewTemplate(option); }}>
|
||||
<ZoomInOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.25, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", minHeight: 148 }}>
|
||||
<Typography variant="caption" sx={{ display: "block", color: option.accent, fontWeight: 700, mb: 0.5 }}>{option.sampleHeading}</Typography>
|
||||
<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>
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mb: 1 }}>{option.sampleMeta}</Typography>
|
||||
{option.sampleBullets.map((bullet) => (
|
||||
<Typography key={bullet} variant="caption" sx={{ display: "block", color: "text.primary", mb: 0.5 }}>• {bullet}</Typography>
|
||||
@@ -821,7 +839,7 @@ export default function ProfilePage() {
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.75 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
|
||||
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
|
||||
@@ -833,7 +851,13 @@ export default function ProfilePage() {
|
||||
<MenuItem value="Projects">{t("profileCvSectionProjects")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label={t("profileCvSectionTargetRole")} value={cvSectionTargetRole} onChange={(e) => setCvSectionTargetRole(e.target.value)} fullWidth helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : undefined} />
|
||||
<TextField
|
||||
label={t("profileCvSectionTargetRole")}
|
||||
value={cvSectionTargetRole}
|
||||
onChange={(e) => setCvSectionTargetRole(e.target.value)}
|
||||
fullWidth
|
||||
helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."}
|
||||
/>
|
||||
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
||||
<InputLabel>Saved job context</InputLabel>
|
||||
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
|
||||
@@ -845,27 +869,121 @@ export default function ProfilePage() {
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Rewrite preview</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · {cvSection || "Whole CV"}</Typography>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Builder output</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{selectedRewriteTemplate.title} · {rewritePreview?.targetRole || selectedRewriteJob?.jobTitle || cvSectionTargetRole || "General reuse"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||
onClick={async () => {
|
||||
setRewritingSection(true);
|
||||
try {
|
||||
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", {
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
sourceText: profileCvText.trim() || null,
|
||||
});
|
||||
setRewritePreview(res.data);
|
||||
toast(t("profileCvSectionRewritten"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
|
||||
} finally {
|
||||
setRewritingSection(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rewritingSection ? t("profileCvSectionRewriting") : rewriteReady ? "Refresh preview" : "Build preview"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!rewriteReady}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await api.post("/profile-cv/export-pdf", {
|
||||
sectionName: cvSection || null,
|
||||
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) {
|
||||
toast(String(e?.response?.data || e?.message || "Failed to export the CV PDF."), "error");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "0.9fr 1.1fr" }, gap: 1.5 }}>
|
||||
<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 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{rewritePreview?.sectionName || "Full rewritten CV text"}</Typography>
|
||||
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
|
||||
</Box>
|
||||
{cvSectionDraft.trim() ? <Chip size="small" color="success" label="Draft ready" /> : null}
|
||||
</Box>
|
||||
<Box sx={{ minHeight: 180, borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
|
||||
{cvSectionDraft.trim() ? (
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{cvSectionDraft}</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a CV style, optionally aim it at a saved job, and generate a rewrite preview here.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="text" disabled={!cvSectionDraft.trim()} onClick={() => navigator.clipboard.writeText(cvSectionDraft)}>{t("profileCopyCvText")}</Button>
|
||||
<Button variant="outlined" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => cvSection ? `${prev.trim()}\n\n${cvSection}\n${cvSectionDraft.trim()}`.trim() : cvSectionDraft.trim())}>{cvSection ? t("profileCvSectionAppend") : "Use as full CV"}</Button>
|
||||
<Button variant="contained" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => cvSection ? replaceCvSection(prev, cvSection, cvSectionDraft) : cvSectionDraft.trim())}>{cvSection ? t("profileCvSectionReplace") : "Replace full CV"}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
<Box sx={{ minHeight: 220, maxHeight: 520, overflow: "auto", borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
|
||||
{rewriteReady ? (
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 1.25, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="text" disabled={!rewriteReady} onClick={() => navigator.clipboard.writeText(rewritePreview?.fullText ?? "")}>{t("profileCopyCvText")}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!rewriteReady}
|
||||
onClick={() => {
|
||||
setProfileCvText(rewritePreview?.fullText ?? "");
|
||||
if (rewritePreview?.structuredCv) setStructuredCv(normalizeStructuredCv(rewritePreview.structuredCv));
|
||||
}}
|
||||
>
|
||||
Replace master CV
|
||||
</Button>
|
||||
</Box>
|
||||
</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>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Styled preview</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · print-ready layout</Typography>
|
||||
</Box>
|
||||
{rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
|
||||
</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, spacing, and hierarchy before you apply it.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Dialog open={Boolean(rewritePreviewTemplate)} onClose={() => setRewritePreviewTemplate(null)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{rewritePreviewTemplate?.title ?? "Template preview"}</DialogTitle>
|
||||
|
||||
@@ -146,8 +146,8 @@ beforeEach(() => {
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === '/profile-cv/rewrite-section') {
|
||||
return Promise.resolve({ data: { text: 'Professional Summary\nClearer, sharper positioning for backend platform roles.' } } as any);
|
||||
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);
|
||||
}
|
||||
if (url === '/profile-cv/reprocess') {
|
||||
return Promise.resolve({ data: { reprocessed: true } } as any);
|
||||
@@ -228,16 +228,16 @@ test('profile page keeps raw extraction collapsed until expanded', async () => {
|
||||
test('profile page rewrite tools use selected template and saved job context', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/cv style rewrite studio/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText(/harvard/i));
|
||||
fireEvent.mouseDown(screen.getAllByRole('combobox')[1]);
|
||||
fireEvent.click(await screen.findByText(/senior backend engineer · acme systems/i));
|
||||
|
||||
const rewriteButton = screen.getByRole('button', { name: /rewrite section/i });
|
||||
const rewriteButton = screen.getByRole('button', { name: /build preview/i });
|
||||
fireEvent.click(rewriteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-section', expect.objectContaining({
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-preview', expect.objectContaining({
|
||||
sectionName: null,
|
||||
style: 'harvard',
|
||||
templateId: 'harvard',
|
||||
@@ -245,7 +245,7 @@ test('profile page rewrite tools use selected template and saved job context', a
|
||||
}));
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/draft ready/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export type StructuredCvJob = {
|
||||
|
||||
export type StructuredCvEducation = {
|
||||
qualification?: string;
|
||||
qualificationLevel?: string;
|
||||
institution?: string;
|
||||
location?: string;
|
||||
start?: string;
|
||||
@@ -51,6 +52,24 @@ export type StructuredCvEducation = {
|
||||
details: string[];
|
||||
};
|
||||
|
||||
export type StructuredCvCertification = {
|
||||
name?: string;
|
||||
issuer?: string;
|
||||
location?: string;
|
||||
date?: string;
|
||||
details: string[];
|
||||
};
|
||||
|
||||
export type StructuredCvProject = {
|
||||
name?: string;
|
||||
role?: string;
|
||||
location?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
bullets: string[];
|
||||
skills: string[];
|
||||
};
|
||||
|
||||
export type StructuredCvLanguage = {
|
||||
name?: string;
|
||||
level?: string;
|
||||
@@ -69,6 +88,8 @@ export type StructuredCvProfile = {
|
||||
summary: string[];
|
||||
jobs: StructuredCvJob[];
|
||||
education: StructuredCvEducation[];
|
||||
certifications: StructuredCvCertification[];
|
||||
projects: StructuredCvProject[];
|
||||
skills: string[];
|
||||
languages: StructuredCvLanguage[];
|
||||
interests: string[];
|
||||
@@ -95,6 +116,8 @@ export function emptyStructuredCv(): StructuredCvProfile {
|
||||
summary: [],
|
||||
jobs: [],
|
||||
education: [],
|
||||
certifications: [],
|
||||
projects: [],
|
||||
skills: [],
|
||||
languages: [],
|
||||
interests: [],
|
||||
@@ -214,6 +237,7 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
|
||||
education: Array.isArray(source.education)
|
||||
? source.education.map((education: any) => ({
|
||||
qualification: normalizeString(education?.qualification),
|
||||
qualificationLevel: normalizeString(education?.qualificationLevel),
|
||||
institution: normalizeString(education?.institution),
|
||||
location: normalizeString(education?.location),
|
||||
start: normalizeString(education?.start),
|
||||
@@ -221,6 +245,26 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
|
||||
details: normalizeList(education?.details),
|
||||
}))
|
||||
: [],
|
||||
certifications: Array.isArray(source.certifications)
|
||||
? source.certifications.map((certification: any) => ({
|
||||
name: normalizeString(certification?.name),
|
||||
issuer: normalizeString(certification?.issuer),
|
||||
location: normalizeString(certification?.location),
|
||||
date: normalizeString(certification?.date),
|
||||
details: normalizeList(certification?.details),
|
||||
}))
|
||||
: [],
|
||||
projects: Array.isArray(source.projects)
|
||||
? source.projects.map((project: any) => ({
|
||||
name: normalizeString(project?.name),
|
||||
role: normalizeString(project?.role),
|
||||
location: normalizeString(project?.location),
|
||||
start: normalizeString(project?.start),
|
||||
end: normalizeString(project?.end),
|
||||
bullets: normalizeList(project?.bullets),
|
||||
skills: normalizeList(project?.skills),
|
||||
}))
|
||||
: [],
|
||||
skills: normalizeList(source.skills),
|
||||
languages: Array.isArray(source.languages)
|
||||
? source.languages.map((language: any) => ({
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface TailoredCvExperienceItem {
|
||||
|
||||
export interface TailoredCvEducationItem {
|
||||
qualification?: string | null;
|
||||
qualificationLevel?: string | null;
|
||||
institution?: string | null;
|
||||
location?: string | null;
|
||||
start?: string | null;
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUTPUT_DIR="${CV_BENCHMARK_OUTPUT_DIR:-$ROOT_DIR/tmp/cv-benchmarks/latest}"
|
||||
APPROVED_DIR="${CV_BENCHMARK_APPROVED_DIR:-$ROOT_DIR/tmp/cv-benchmarks/approved-fixtures}"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR" "$APPROVED_DIR"
|
||||
|
||||
echo "CV benchmark output: $OUTPUT_DIR"
|
||||
echo "Approved fixtures: $APPROVED_DIR"
|
||||
|
||||
CV_BENCHMARK_OUTPUT_DIR="$OUTPUT_DIR" \
|
||||
CV_BENCHMARK_APPROVED_DIR="$APPROVED_DIR" \
|
||||
dotnet test "$ROOT_DIR/JobTrackerApi.Tests/JobTrackerApi.Tests.csproj" --filter CvCorpusHarnessTests
|
||||
|
||||
if [[ -f "$OUTPUT_DIR/report.md" ]]; then
|
||||
echo
|
||||
echo "Benchmark report written to: $OUTPUT_DIR/report.md"
|
||||
fi
|
||||
if [[ -f "$OUTPUT_DIR/index.json" ]]; then
|
||||
echo "Benchmark index written to: $OUTPUT_DIR/index.json"
|
||||
fi
|
||||
@@ -34,7 +34,7 @@ python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
|
||||
The Dockerfile installs Tesseract OCR so scanned PDFs and supported images can be processed inside the container.
|
||||
|
||||
## API
|
||||
- `GET /health` — health check and runtime capabilities
|
||||
- `GET /health` — health check and runtime capabilities, including Ollama version/model metadata when configured
|
||||
- `POST /summarize` — JSON body `{ "text": "...", "max_length": 150, "min_length": 30 }`
|
||||
- `POST /extract-text` — multipart file upload, returns extracted text and OCR metadata
|
||||
- `POST /cv/classify-block` — JSON body `{ "block": "..." }`, uses Ollama when `OLLAMA_MODEL` is configured
|
||||
|
||||
+27
-5
@@ -63,6 +63,12 @@ def _key(text: str, max_length: int, min_length: int, top_skills: int) -> str:
|
||||
return f"{h}:{max_length}:{min_length}:{top_skills}"
|
||||
|
||||
|
||||
def _ollama_json(path: str):
|
||||
req = urllib_request.Request(f"{OLLAMA_BASE_URL}{path}", method="GET")
|
||||
with urllib_request.urlopen(req, timeout=5) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _ollama_status():
|
||||
configured = bool(OLLAMA_MODEL)
|
||||
if not configured:
|
||||
@@ -71,27 +77,43 @@ def _ollama_status():
|
||||
"ollama_reachable": False,
|
||||
"ollama_model": None,
|
||||
"ollama_model_available": False,
|
||||
"ollama_version": None,
|
||||
"ollama_installed_models": [],
|
||||
"ollama_loaded_models": [],
|
||||
"ollama_loaded_count": 0,
|
||||
}
|
||||
|
||||
req = urllib_request.Request(f"{OLLAMA_BASE_URL}/api/tags", method="GET")
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=5) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
tags_body = _ollama_json("/api/tags")
|
||||
version_body = _ollama_json("/api/version")
|
||||
try:
|
||||
ps_body = _ollama_json("/api/ps")
|
||||
except Exception:
|
||||
ps_body = {"models": []}
|
||||
except Exception:
|
||||
return {
|
||||
"ollama_configured": True,
|
||||
"ollama_reachable": False,
|
||||
"ollama_model": OLLAMA_MODEL,
|
||||
"ollama_model_available": False,
|
||||
"ollama_version": None,
|
||||
"ollama_installed_models": [],
|
||||
"ollama_loaded_models": [],
|
||||
"ollama_loaded_count": 0,
|
||||
}
|
||||
|
||||
models = body.get("models") or []
|
||||
names = {item.get("name") for item in models if isinstance(item, dict)}
|
||||
models = tags_body.get("models") or []
|
||||
names = sorted({item.get("name") for item in models if isinstance(item, dict) and item.get("name")})
|
||||
loaded_models = sorted({item.get("name") for item in (ps_body.get("models") or []) if isinstance(item, dict) and item.get("name")})
|
||||
return {
|
||||
"ollama_configured": True,
|
||||
"ollama_reachable": True,
|
||||
"ollama_model": OLLAMA_MODEL,
|
||||
"ollama_model_available": OLLAMA_MODEL in names,
|
||||
"ollama_version": version_body.get("version"),
|
||||
"ollama_installed_models": names,
|
||||
"ollama_loaded_models": loaded_models,
|
||||
"ollama_loaded_count": len(loaded_models),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ def test_health_reports_runtime_without_ollama(monkeypatch):
|
||||
assert payload["device"] == "cpu"
|
||||
assert payload["ollama_configured"] is False
|
||||
assert payload["ollama_model"] is None
|
||||
assert payload["ollama_installed_models"] == []
|
||||
assert payload["ollama_loaded_models"] == []
|
||||
|
||||
|
||||
def test_classify_block_returns_structured_json(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user