feat: add cv benchmark workflow and admin visibility

This commit is contained in:
2026-04-01 12:25:45 +02:00
parent 0551a525a8
commit 0d65835857
16 changed files with 832 additions and 95 deletions
+212 -21
View File
@@ -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,
+8
View File
@@ -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,
+224
View File
@@ -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();
});
+41 -1
View File
@@ -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>
</>
+178 -60
View File
@@ -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>
+6 -6
View File
@@ -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
View File
@@ -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) => ({
+1
View File
@@ -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;
+23
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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),
}
+2
View File
@@ -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):