Add canonical CV artifact pipeline
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user