Add canonical CV artifact pipeline

This commit is contained in:
2026-03-28 23:32:54 +01:00
parent d8ab312f59
commit 107c181506
10 changed files with 619 additions and 82 deletions
+2
View File
@@ -22,3 +22,5 @@
| D014 | M001/S06/T03 | acceptance-run | How the S06 live acceptance runner should authenticate seeding and protected UI verification without requiring manual token export every rerun. | Allow the S06 acceptance runner to mint a localhost-only admin JWT from the checked-in dev JWT settings plus the local SQLite admin record when AUTH_TOKEN is missing. | The current DB snapshot contains an admin user but the placeholder development password is not reliable, and the tasks verification command must stay repeatable. A localhost-only signed JWT fallback keeps the run fully local, avoids secret prompts, does not print token material, and still exercises the real protected API/UI paths. | Yes | agent |
| D015 | M001/S06 | environment | S06 preflight auth handling | Treat /api/auth/config reachability plus an auth-limited /api/admin/system probe as a guided partial-pass, and never echo bearer tokens in preflight output. | S06 needs a repeatable go/no-go gate before browser UAT. The live stack can be healthy even when admin-only diagnostics require an extra token, so the preflight should fail hard only for unreachable/malformed API responses while still surfacing clear AUTH_TOKEN guidance and protecting secrets on shared terminals. | Yes | agent |
| D016 | M001/S07 | uat-artifact | How S07 daily-loop closure should capture acceptance evidence | Keep docs/s06-acceptance-run.md as the canonical execution log and use S07 closure artifacts to summarize/import the cross-surface proof rather than duplicating raw runner output. | S07's job is to prove one seeded job stays coherent across /jobs, workspace, /reminders, and /dashboard while preserving the manual-send boundary. Reusing the S06 runner output as the canonical source keeps reruns idempotent, prevents drift between generated logs and human summary text, and gives downstream slices one stable place for detailed evidence plus one concise dependency summary. | Yes | agent |
| D017 | M005 planning | delivery | How M005 execution should be staged and published | Execute M005 one slice at a time, verify each slice independently, push each slice on its own git branch, then continue to the next slice only after the prior slice is stable. | The CV intelligence/export milestone is high-risk and multi-layered. Slice-by-slice branching and push discipline will keep extraction, tailored draft, and PDF rendering changes reviewable and reduce regression blast radius. | Yes | human |
| D018 | M005 planning | verification | What document corpus should drive universal CV extraction verification | Use the real CV files placed in /home/pi/cvs as a regression corpus for universal extractor work, alongside synthetic/unit fixtures. | A universal CV extractor cannot be validated only against synthetic fixtures. Real CVs with different layouts, OCR quality, and structure are required to test extraction, review UX, and rendering assumptions. | Yes | human |
+20
View File
@@ -22,6 +22,8 @@ namespace JobTrackerApi.Data
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
public DbSet<SystemEmailSettings> SystemEmailSettings => Set<SystemEmailSettings>();
public DbSet<JobEvent> JobEvents => Set<JobEvent>();
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -81,6 +83,24 @@ namespace JobTrackerApi.Data
.WithMany(j => j.Events)
.HasForeignKey(e => e.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CvUploadArtifact>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CvUploadArtifact>()
.HasIndex(x => new { x.OwnerUserId, x.UploadedAtUtc });
modelBuilder.Entity<CvExtractionRun>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CvExtractionRun>()
.HasIndex(x => new { x.OwnerUserId, x.StartedAtUtc });
modelBuilder.Entity<CvExtractionRun>()
.HasOne(x => x.Artifact)
.WithMany()
.HasForeignKey(x => x.ArtifactId)
.OnDelete(DeleteBehavior.SetNull);
}
}
}
+174 -28
View File
@@ -2,11 +2,15 @@ using System.Security.Claims;
using System.Text;
using System.Text.Json;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
@@ -19,15 +23,14 @@ public sealed class ProfileCvControllerTests
[Fact]
public async Task Upload_rejects_unsupported_extension()
{
var user = new ApplicationUser();
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("hello")), 0, 5, "file", "resume.exe");
var result = await controller.Upload(file);
@@ -36,6 +39,116 @@ public sealed class ProfileCvControllerTests
Assert.True((badRequest.Value?.ToString() ?? string.Empty).Contains("supported", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Upload_stores_cv_artifact_and_extraction_run_metadata()
{
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiTextExtractionResult("# Connor Babbington\n\n## Professional Summary\nBuilt APIs and UIs", false, "text/markdown", null, 62, "resume.md"));
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("""
{
"version":"1",
"contact":{"fullName":"Connor Babbington"},
"summary":["Built APIs and UIs"],
"jobs":[],
"education":[],
"skills":[],
"languages":[],
"interests":[],
"otherSections":[]
}
""");
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("# Connor Babbington\n\n## Professional Summary\nBuilt APIs and UIs")), 0, 62, "file", "resume.md")
{
Headers = new HeaderDictionary(),
ContentType = "text/markdown"
};
var result = await controller.Upload(file);
Assert.IsType<OkObjectResult>(result);
var artifact = await db.CvUploadArtifacts.SingleAsync();
var run = await db.CvExtractionRuns.SingleAsync();
Assert.Equal("user-1", artifact.OwnerUserId);
Assert.Equal("resume.md", artifact.OriginalFileName);
Assert.True(System.IO.File.Exists(artifact.StoragePath));
Assert.Equal("applied", run.Status);
Assert.Equal("upload", run.Trigger);
Assert.Equal(artifact.Id, run.ArtifactId);
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
Assert.Equal(artifact.Id, user.CurrentCvUploadArtifactId);
Assert.Equal(1, user.CurrentCvProfileVersion);
}
[Fact]
public async Task Reprocess_uses_latest_stored_artifact_and_creates_a_new_run()
{
var user = new ApplicationUser { Id = "user-1", CurrentCvProfileVersion = 1 };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiTextExtractionResult("# Connor Babbington\n\n## Professional Summary\nRefined profile", false, "text/markdown", null, 49, "resume.md"));
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("""
{
"version":"1",
"contact":{"fullName":"Connor Babbington"},
"summary":["Refined profile"],
"jobs":[],
"education":[],
"skills":[],
"languages":[],
"interests":[],
"otherSections":[]
}
""");
await using var db = CreateDb();
var paths = CreatePaths();
var artifactPath = Path.Combine(paths.CvArtifactsRoot, "user-1", "resume.md");
Directory.CreateDirectory(Path.GetDirectoryName(artifactPath)!);
await System.IO.File.WriteAllTextAsync(artifactPath, "# Connor Babbington\n\n## Professional Summary\nLegacy profile");
db.CvUploadArtifacts.Add(new CvUploadArtifact
{
OwnerUserId = "user-1",
OriginalFileName = "resume.md",
StoredFileName = "resume.md",
MimeType = "text/markdown",
ByteSize = 58,
Sha256 = "ABC",
StoragePath = artifactPath,
UploadedAtUtc = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var result = await controller.Reprocess();
Assert.IsType<OkObjectResult>(result);
var run = await db.CvExtractionRuns.SingleAsync();
Assert.Equal("reprocess", run.Trigger);
Assert.Equal("applied", run.Status);
Assert.Equal(2, user.CurrentCvProfileVersion);
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
Assert.Equal("# Connor Babbington\n\n## Professional Summary\nRefined profile", user.ProfileCvText);
}
[Fact]
public async Task Upload_reconstructs_flattened_pdf_cv_before_save()
{
@@ -83,7 +196,7 @@ public sealed class ProfileCvControllerTests
}
""";
var user = new ApplicationUser();
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
@@ -98,10 +211,9 @@ public sealed class ProfileCvControllerTests
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), reconstructed, 3200, 900))
.ReturnsAsync(structuredJson);
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var bytes = Encoding.UTF8.GetBytes("fake pdf bytes");
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "Resume.en.pdf")
@@ -133,7 +245,7 @@ public sealed class ProfileCvControllerTests
{
var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 E D U C A T I O N E X T E N D E D D I P L O M A N V Q L E V E L 3 I N I C T 2012 - 2015 F O L L O W A B O U T M E Mid-level system developer with eight years of experience in UK local government, with expertise in full-stack development, backend, frontend and server administration. I N T E R E S T S I am interested in PC and board games, as well as cooking and learning new skills. E X P E R I E N C E S Y S T E M D E V E L O P E R 2015 - 2023 Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript. + Warwickshire County Council, UK C O N T A C T Native English speaker, Norwegian level A2/B1.";
var user = new ApplicationUser();
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
@@ -148,10 +260,9 @@ public sealed class ProfileCvControllerTests
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var bytes = Encoding.UTF8.GetBytes("fake pdf bytes");
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "Resume.en.pdf")
@@ -180,6 +291,7 @@ public sealed class ProfileCvControllerTests
{
var user = new ApplicationUser
{
Id = "user-1",
ProfileCvText = "# Connor Babbington\n\n## Contact\nconnor@example.com\n+47 41 33 44 70\n\n## Professional Summary\nBuilt backend systems.\n\n## Work Experience\n### System Developer\nWarwickshire County Council\n2015 - 2023\n- Built APIs\n\n## Education\n### Warwickshire College\n2012 - 2015"
};
var structuredJson = """
@@ -225,10 +337,9 @@ public sealed class ProfileCvControllerTests
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), user.ProfileCvText, 3200, 900))
.ReturnsAsync(structuredJson);
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(user.ProfileCvText));
@@ -249,6 +360,7 @@ public sealed class ProfileCvControllerTests
{
var user = new ApplicationUser
{
Id = "user-1",
ProfileCvText = "# Connor Babbington\n\n## Professional Summary\nBuilt backend systems.\n\n## Skills\n.NET\nSQL\nAzure"
};
var userManager = CreateUserManager();
@@ -259,10 +371,9 @@ public sealed class ProfileCvControllerTests
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), user.ProfileCvText, 3200, 900))
.ReturnsAsync("not-json");
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(user.ProfileCvText));
@@ -280,7 +391,7 @@ public sealed class ProfileCvControllerTests
[Fact]
public async Task Upload_accepts_markdown_cv_and_saves_text()
{
var user = new ApplicationUser();
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
@@ -304,10 +415,9 @@ public sealed class ProfileCvControllerTests
}
""");
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("# Connor Babbington\n\n## Professional Summary\nBuilt APIs and UIs")), 0, 62, "file", "resume.md")
{
@@ -321,6 +431,42 @@ public sealed class ProfileCvControllerTests
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName);
}
private static ProfileCvController CreateController(UserManager<ApplicationUser> userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths)
{
return new ProfileCvController(userManager, aiService, db, paths)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
}
private static JobTrackerContext CreateDb(string userId = "user-1")
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns(userId);
return new JobTrackerContext(options, currentUser.Object);
}
private static AppPaths CreatePaths()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Data:Root"] = tempRoot,
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts")
})
.Build();
var env = new Mock<IHostEnvironment>();
env.SetupGet(x => x.ContentRootPath).Returns(tempRoot);
return new AppPaths(config, env.Object);
}
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
+235 -54
View File
@@ -1,11 +1,14 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Services;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
@@ -52,19 +55,28 @@ public sealed class ProfileCvController : ControllerBase
};
private const long MaxFileSizeBytes = 5 * 1024 * 1024;
private const string ParserVersion = "m005-s01";
private const string NormalizerVersion = "m005-s01";
private const string LlmPromptVersion = "m005-s01";
private readonly UserManager<ApplicationUser> _users;
private readonly ISummarizerService _aiService;
private readonly JobTrackerContext _db;
private readonly AppPaths _paths;
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService)
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths)
{
_users = users;
_aiService = aiService;
_db = db;
_paths = paths;
}
public sealed record RewriteSectionRequest(string SectionName, string? Style, string? TargetRole);
public sealed record ParseCvRequest(string? Text);
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
[HttpPost("upload")]
[RequestSizeLimit(MaxFileSizeBytes)]
public async Task<IActionResult> Upload([FromForm] IFormFile file)
@@ -80,48 +92,113 @@ public sealed class ProfileCvController : ControllerBase
return BadRequest("Only .txt, .md, .pdf, .docx, .png, .jpg, .jpeg, and .webp CV imports are supported right now.");
}
string text;
var canUseAiExtraction = string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".docx", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".webp", StringComparison.OrdinalIgnoreCase);
var artifact = await SaveUploadArtifactAsync(user, file, HttpContext.RequestAborted);
_db.CvUploadArtifacts.Add(artifact);
await _db.SaveChangesAsync(HttpContext.RequestAborted);
if (canUseAiExtraction)
var run = new CvExtractionRun
{
await using var uploadStream = file.OpenReadStream();
var extracted = await _aiService.ExtractTextAsync(uploadStream, file.FileName ?? $"cv{extension}", file.ContentType, HttpContext.RequestAborted);
text = extracted?.Text?.Trim() ?? string.Empty;
OwnerUserId = user.Id,
ArtifactId = artifact.Id,
Trigger = "upload",
ParserVersion = ParserVersion,
NormalizerVersion = NormalizerVersion,
LlmPromptVersion = LlmPromptVersion,
Status = "running",
StartedAtUtc = DateTimeOffset.UtcNow,
};
_db.CvExtractionRuns.Add(run);
await _db.SaveChangesAsync(HttpContext.RequestAborted);
try
{
var result = await ExtractStructuredCvFromFileAsync(file, extension, HttpContext.RequestAborted);
result.StructuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
result.StructuredCv.Metadata.AppliedExtractionRunId = run.Id;
result.StructuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
var structuredJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
run.RawExtractedText = result.RawText;
run.NormalizedText = result.NormalizedText;
run.StructuredProfileJson = structuredJson;
run.Status = "applied";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
run.AppliedAtUtc = run.CompletedAtUtc;
user.ProfileCvText = result.NormalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvUploadArtifactId = artifact.Id;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = result.StructuredCv.Metadata.ProfileVersion;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
run.Status = "failed";
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return BadRequest(run.ErrorMessage);
}
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return Ok(new
{
imported = true,
characters = result.NormalizedText.Length,
structuredCv = result.StructuredCv,
sections = result.StructuredCv.Sections,
artifactId = artifact.Id,
extractionRunId = run.Id,
profileVersion = result.StructuredCv.Metadata.ProfileVersion,
});
}
else
catch (Exception ex)
{
text = string.Empty;
run.Status = "failed";
run.ErrorMessage = ex.Message;
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(HttpContext.RequestAborted);
throw;
}
}
[HttpPost("reprocess")]
public async Task<IActionResult> Reprocess()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var artifact = await _db.CvUploadArtifacts
.OrderByDescending(x => x.UploadedAtUtc)
.FirstOrDefaultAsync(x => x.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (artifact is null) return BadRequest("Upload a CV before reprocessing it.");
if (string.IsNullOrWhiteSpace(artifact.StoragePath) || !System.IO.File.Exists(artifact.StoragePath))
{
return BadRequest("The stored CV artifact could not be found for reprocessing.");
}
if (string.IsNullOrWhiteSpace(text))
await using var stream = System.IO.File.OpenRead(artifact.StoragePath);
var file = new FormFile(stream, 0, stream.Length, "file", artifact.OriginalFileName)
{
text = (await ExtractTextAsync(file, extension)).Trim();
}
if (string.IsNullOrWhiteSpace(text))
Headers = new HeaderDictionary(),
ContentType = artifact.MimeType
};
var extension = Path.GetExtension(artifact.OriginalFileName ?? string.Empty);
var result = await ExtractStructuredCvFromFileAsync(file, extension, HttpContext.RequestAborted);
await ApplyTextExtractionRunAsync(user, "reprocess", result.RawText, result.NormalizedText, result.StructuredCv, artifact.Id, HttpContext.RequestAborted);
return Ok(new
{
return BadRequest("The uploaded CV file could not be read or was empty.");
}
text = (await MaybeReconstructStructuredCvAsync(text, HttpContext.RequestAborted)).Trim();
var structuredCv = await BuildStructuredCvAsync(text, HttpContext.RequestAborted);
user.ProfileCvText = text;
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(structuredCv);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new { imported = true, characters = text.Length, structuredCv, sections = structuredCv.Sections });
reprocessed = true,
artifactId = artifact.Id,
extractionRunId = user.CurrentCvExtractionRunId,
profileVersion = user.CurrentCvProfileVersion,
structuredCv = result.StructuredCv,
sections = result.StructuredCv.Sections,
});
}
[HttpPost("rebuild")]
@@ -144,14 +221,9 @@ public sealed class ProfileCvController : ControllerBase
user.ProfileCvText = rebuilt.Trim();
var structuredCv = await BuildStructuredCvAsync(user.ProfileCvText, HttpContext.RequestAborted);
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(structuredCv);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
await ApplyTextExtractionRunAsync(user, "rebuild", user.ProfileCvText, user.ProfileCvText, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted);
return Ok(new { rebuilt = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText, structuredCv, sections = structuredCv.Sections });
return Ok(new { rebuilt = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText, structuredCv, sections = structuredCv.Sections, extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion });
}
[HttpPost("rewrite-section")]
@@ -189,14 +261,13 @@ public sealed class ProfileCvController : ControllerBase
if (string.IsNullOrWhiteSpace(source)) return BadRequest("Add or import CV text before parsing sections.");
var structuredCv = await BuildStructuredCvAsync(source, HttpContext.RequestAborted);
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(structuredCv);
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
if (string.IsNullOrWhiteSpace(request?.Text))
{
return BadRequest(string.Join("; ", update.Errors.Select(e => e.Description)));
user.ProfileCvText = source;
}
await ApplyTextExtractionRunAsync(user, "parse", source, source, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted);
return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(source) });
return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(source), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion });
}
[HttpPost("improve")]
@@ -219,14 +290,9 @@ public sealed class ProfileCvController : ControllerBase
user.ProfileCvText = improved.Trim();
var structuredCv = await BuildStructuredCvAsync(user.ProfileCvText, HttpContext.RequestAborted);
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(structuredCv);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
await ApplyTextExtractionRunAsync(user, "improve", user.ProfileCvText, user.ProfileCvText, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted);
return Ok(new { improved = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText, structuredCv, sections = structuredCv.Sections });
return Ok(new { improved = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText, structuredCv, sections = structuredCv.Sections, extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion });
}
private async Task<StructuredCvProfile> BuildStructuredCvAsync(string text, CancellationToken cancellationToken)
@@ -252,6 +318,121 @@ public sealed class ProfileCvController : ControllerBase
return StructuredCvProfileJson.Normalize(merged);
}
private async Task<CvUploadArtifact> SaveUploadArtifactAsync(ApplicationUser user, IFormFile file, CancellationToken cancellationToken)
{
var extension = Path.GetExtension(file.FileName ?? string.Empty);
var userRoot = Path.Combine(_paths.CvArtifactsRoot, user.Id);
Directory.CreateDirectory(userRoot);
var storedFileName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension}";
var storagePath = Path.Combine(userRoot, storedFileName);
await using (var target = System.IO.File.Create(storagePath))
await using (var source = file.OpenReadStream())
{
await source.CopyToAsync(target, cancellationToken);
}
await using var hashStream = System.IO.File.OpenRead(storagePath);
var shaBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
return new CvUploadArtifact
{
OwnerUserId = user.Id,
OriginalFileName = file.FileName ?? storedFileName,
StoredFileName = storedFileName,
MimeType = file.ContentType ?? "application/octet-stream",
ByteSize = file.Length,
Sha256 = Convert.ToHexString(shaBytes),
StoragePath = storagePath,
UploadedAtUtc = DateTimeOffset.UtcNow,
};
}
private async Task<ExtractionPipelineResult> ExtractStructuredCvFromFileAsync(IFormFile file, string extension, CancellationToken cancellationToken)
{
string text;
var canUseAiExtraction = string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".docx", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".webp", StringComparison.OrdinalIgnoreCase);
if (canUseAiExtraction)
{
await using var uploadStream = file.OpenReadStream();
var extracted = await _aiService.ExtractTextAsync(uploadStream, file.FileName ?? $"cv{extension}", file.ContentType, cancellationToken);
text = extracted?.Text?.Trim() ?? string.Empty;
}
else
{
text = string.Empty;
}
if (string.IsNullOrWhiteSpace(text))
{
text = (await ExtractTextAsync(file, extension)).Trim();
}
if (string.IsNullOrWhiteSpace(text))
{
throw new InvalidOperationException("The uploaded CV file could not be read or was empty.");
}
var normalizedText = (await MaybeReconstructStructuredCvAsync(text, cancellationToken)).Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
}
private async Task ApplyTextExtractionRunAsync(ApplicationUser user, string trigger, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken)
{
var run = new CvExtractionRun
{
OwnerUserId = user.Id,
ArtifactId = artifactId,
Trigger = trigger,
ParserVersion = ParserVersion,
NormalizerVersion = NormalizerVersion,
LlmPromptVersion = LlmPromptVersion,
Status = "applied",
RawExtractedText = rawText,
NormalizedText = normalizedText,
StartedAtUtc = DateTimeOffset.UtcNow,
CompletedAtUtc = DateTimeOffset.UtcNow,
AppliedAtUtc = DateTimeOffset.UtcNow,
};
_db.CvExtractionRuns.Add(run);
await _db.SaveChangesAsync(cancellationToken);
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.StructuredProfileJson = structuredJson;
user.ProfileCvText = normalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
if (artifactId.HasValue)
{
user.CurrentCvUploadArtifactId = artifactId.Value;
}
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
run.Status = "failed";
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
await _db.SaveChangesAsync(cancellationToken);
throw new InvalidOperationException(run.ErrorMessage);
}
await _db.SaveChangesAsync(cancellationToken);
}
private async Task<StructuredCvProfile?> TryExtractStructuredCvAsync(string text, CancellationToken cancellationToken)
{
var structuredJson = await _aiService.SummarizeSectionAsync(
+121
View File
@@ -351,6 +351,10 @@ CREATE TABLE IF NOT EXISTS `AspNetUsers` (
`LastName` longtext NULL,
`DisplayName` longtext NULL,
`ProfileCvText` longtext NULL,
`ProfileCvStructureJson` longtext NULL,
`CurrentCvUploadArtifactId` int NULL,
`CurrentCvExtractionRunId` int NULL,
`CurrentCvProfileVersion` int NULL,
`AvatarImageDataUrl` longtext NULL,
`GoogleSubject` longtext NULL,
`GoogleEmail` longtext NULL,
@@ -504,6 +508,10 @@ CREATE TABLE IF NOT EXISTS "AspNetUsers" (
"LastName" TEXT NULL,
"DisplayName" TEXT NULL,
"ProfileCvText" TEXT NULL,
"ProfileCvStructureJson" TEXT NULL,
"CurrentCvUploadArtifactId" INTEGER NULL,
"CurrentCvExtractionRunId" INTEGER NULL,
"CurrentCvProfileVersion" INTEGER NULL,
"AvatarImageDataUrl" TEXT NULL,
"GoogleSubject" TEXT NULL,
"GoogleEmail" TEXT NULL,
@@ -578,6 +586,9 @@ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvStructureJson TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvUploadArtifactId INTEGER NULL;");
EnsureColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvExtractionRunId INTEGER NULL;");
EnsureColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvProfileVersion INTEGER NULL;");
EnsureColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE AspNetUsers ADD COLUMN AvatarImageDataUrl TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
@@ -622,7 +633,50 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
}
static void EnsureCvTables(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "CvUploadArtifacts" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvUploadArtifacts" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"OriginalFileName" TEXT NOT NULL,
"StoredFileName" TEXT NOT NULL,
"MimeType" TEXT NOT NULL,
"ByteSize" INTEGER NOT NULL,
"Sha256" TEXT NOT NULL,
"StoragePath" TEXT NOT NULL,
"UploadedAtUtc" TEXT NOT NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CvExtractionRuns" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvExtractionRuns" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"ArtifactId" INTEGER NULL,
"Trigger" TEXT NOT NULL,
"ParserVersion" TEXT NOT NULL,
"NormalizerVersion" TEXT NOT NULL,
"LlmPromptVersion" TEXT NOT NULL,
"Status" TEXT NOT NULL,
"RawExtractedText" TEXT NULL,
"NormalizedText" TEXT NULL,
"StructuredProfileJson" TEXT NULL,
"ErrorMessage" TEXT NULL,
"StartedAtUtc" TEXT NOT NULL,
"CompletedAtUtc" TEXT NULL,
"AppliedAtUtc" TEXT NULL,
CONSTRAINT "FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId" FOREIGN KEY ("ArtifactId") REFERENCES "CvUploadArtifacts" ("Id") ON DELETE SET NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc" ON "CvUploadArtifacts" ("OwnerUserId", "UploadedAtUtc");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");""");
}
EnsureGmailConnectionsTable(conn);
EnsureCvTables(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
@@ -787,6 +841,9 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvUploadArtifactId` int NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvExtractionRunId` int NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvProfileVersion` int NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE `AspNetUsers` ADD COLUMN `AvatarImageDataUrl` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;");
@@ -811,6 +868,49 @@ PRIMARY KEY (`Id`)
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CvUploadArtifacts"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvUploadArtifacts` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`OriginalFileName` longtext NOT NULL,
`StoredFileName` longtext NOT NULL,
`MimeType` longtext NOT NULL,
`ByteSize` bigint NOT NULL,
`Sha256` longtext NOT NULL,
`StoragePath` longtext NOT NULL,
`UploadedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CvExtractionRuns"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvExtractionRuns` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`ArtifactId` int NULL,
`Trigger` longtext NOT NULL,
`ParserVersion` longtext NOT NULL,
`NormalizerVersion` longtext NOT NULL,
`LlmPromptVersion` longtext NOT NULL,
`Status` longtext NOT NULL,
`RawExtractedText` longtext NULL,
`NormalizedText` longtext NULL,
`StructuredProfileJson` longtext NULL,
`ErrorMessage` longtext NULL,
`StartedAtUtc` datetime(6) NOT NULL,
`CompletedAtUtc` datetime(6) NULL,
`AppliedAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`ArtifactId`) REFERENCES `CvUploadArtifacts` (`Id`) ON DELETE SET NULL
);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
@@ -824,6 +924,27 @@ PRIMARY KEY (`Id`)
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId` ON `JobApplications` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc` ON `CvUploadArtifacts` (`OwnerUserId`, `UploadedAtUtc`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_OwnerUserId_StartedAtUtc` ON `CvExtractionRuns` (`OwnerUserId`, `StartedAtUtc`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);";
cmd.ExecuteNonQuery();
}
}
}
+8
View File
@@ -7,6 +7,7 @@ namespace JobTrackerApi.Services
{
public string DataRoot { get; }
public string AttachmentsRoot { get; }
public string CvArtifactsRoot { get; }
public AppPaths(IConfiguration cfg, IHostEnvironment env)
{
@@ -23,6 +24,13 @@ namespace JobTrackerApi.Services
Directory.CreateDirectory(attachmentsRoot);
AttachmentsRoot = attachmentsRoot;
var cvArtifactsRoot = (cfg["Data:CvArtifactsRoot"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(cvArtifactsRoot)) cvArtifactsRoot = Path.Combine(DataRoot, "CvArtifacts");
if (!Path.IsPathRooted(cvArtifactsRoot)) cvArtifactsRoot = Path.Combine(env.ContentRootPath, cvArtifactsRoot);
Directory.CreateDirectory(cvArtifactsRoot);
CvArtifactsRoot = cvArtifactsRoot;
}
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
+3
View File
@@ -9,6 +9,9 @@ public sealed class ApplicationUser : IdentityUser
public string? DisplayName { get; set; }
public string? ProfileCvText { get; set; }
public string? ProfileCvStructureJson { get; set; }
public int? CurrentCvUploadArtifactId { get; set; }
public int? CurrentCvExtractionRunId { get; set; }
public int? CurrentCvProfileVersion { get; set; }
public string? AvatarImageDataUrl { get; set; }
public string? GoogleSubject { get; set; }
public string? GoogleEmail { get; set; }
+34
View File
@@ -0,0 +1,34 @@
namespace JobTrackerApi.Models;
public sealed class CvUploadArtifact
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public string OriginalFileName { get; set; } = string.Empty;
public string StoredFileName { get; set; } = string.Empty;
public string MimeType { get; set; } = string.Empty;
public long ByteSize { get; set; }
public string Sha256 { get; set; } = string.Empty;
public string StoragePath { get; set; } = string.Empty;
public DateTimeOffset UploadedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class CvExtractionRun
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public int? ArtifactId { get; set; }
public CvUploadArtifact? Artifact { get; set; }
public string Trigger { get; set; } = string.Empty;
public string ParserVersion { get; set; } = string.Empty;
public string NormalizerVersion { get; set; } = string.Empty;
public string LlmPromptVersion { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string? RawExtractedText { get; set; }
public string? NormalizedText { get; set; }
public string? StructuredProfileJson { get; set; }
public string? ErrorMessage { get; set; }
public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? CompletedAtUtc { get; set; }
public DateTimeOffset? AppliedAtUtc { get; set; }
}
+20
View File
@@ -3,6 +3,7 @@ namespace JobTrackerApi.Models;
public sealed class StructuredCvProfile
{
public string Version { get; set; } = "1";
public StructuredCvMetadata Metadata { get; set; } = new();
public StructuredCvContact Contact { get; set; } = new();
public List<string> Summary { get; set; } = new();
public List<StructuredCvJob> Jobs { get; set; } = new();
@@ -14,6 +15,25 @@ public sealed class StructuredCvProfile
public List<StructuredCvSection> Sections { get; set; } = new();
}
public sealed class StructuredCvMetadata
{
public int? ProfileVersion { get; set; }
public int? AppliedExtractionRunId { get; set; }
public DateTimeOffset? UpdatedAtUtc { get; set; }
public Dictionary<string, StructuredCvFieldMetadata> Fields { get; set; } = new();
}
public sealed class StructuredCvFieldMetadata
{
public double? Confidence { get; set; }
public string? Method { get; set; }
public string? SourceSnippet { get; set; }
public int? SourcePage { get; set; }
public string? SourceBlockId { get; set; }
public string? ReviewState { get; set; }
public DateTimeOffset? LastUpdatedAtUtc { get; set; }
}
public sealed class StructuredCvContact
{
public string? FullName { get; set; }
+2
View File
@@ -134,6 +134,8 @@ public static class StructuredCvProfileJson
{
profile ??= new StructuredCvProfile();
profile.Version = string.IsNullOrWhiteSpace(profile.Version) ? "1" : profile.Version.Trim();
profile.Metadata ??= new StructuredCvMetadata();
profile.Metadata.Fields ??= new Dictionary<string, StructuredCvFieldMetadata>();
profile.Contact ??= new StructuredCvContact();
profile.Summary = CleanList(profile.Summary);
profile.Jobs = (profile.Jobs ?? new List<StructuredCvJob>())