fix(cv): bind letter proposals to current sources and preserve document choices

This commit is contained in:
cesnimda
2026-10-26 03:44:37 +01:00
parent 8948c988e2
commit 8979244576
21 changed files with 816 additions and 32 deletions
@@ -50,6 +50,11 @@ public sealed class AiWorkspaceController : ControllerBase
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (_db is not null && string.Equals(request?.Module?.Trim(), "cover-letter", StringComparison.OrdinalIgnoreCase))
{
user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == user.Id, ct);
if (user is null) return Unauthorized();
}
if (string.IsNullOrWhiteSpace(request?.Module)) return BadRequest("Choose an AI module.");
AiUsageReservation? reservation = null;
@@ -93,6 +98,13 @@ public sealed class AiWorkspaceController : ControllerBase
reservation.Record.Id, interaction.InputCharacterCount, interaction.OutputCharacterCount, ct);
return Ok(ToDto(interaction));
}
catch (CoverLetterSourceChangedException ex)
{
if (reservation is not null)
await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).FinalizeAsync(
reservation.Record.Id, ex.InputCharacterCount, ex.OutputCharacterCount, ct);
return Conflict(new { code = "cover_letter_source_changed" });
}
catch (ArgumentException ex)
{
if (reservation is not null) await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).ReleaseAsync(reservation.Record.Id, ct);
@@ -113,6 +125,17 @@ public sealed class AiWorkspaceController : ControllerBase
return Ok(history.Select(ToDto));
}
[HttpPost("history/{id:int}/validate-source")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<IActionResult> ValidateSource(int jobId, int id, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var current = await _workspace.ValidateCoverLetterSourceAsync(user.Id, jobId, id, ct);
return current is null ? NotFound() : current.Value ? NoContent()
: Conflict(new { code = "cover_letter_source_changed" });
}
[HttpDelete("history/{id:int}")]
public async Task<IActionResult> Delete(int jobId, int id, CancellationToken ct)
{
@@ -6,6 +6,7 @@ namespace JobTrackerApi.Models;
public sealed class CoverLetterDocumentSettings
{
public int SchemaVersion { get; set; } = 1;
public string Language { get; set; } = "en";
public string SenderName { get; set; } = "";
public string SenderTitle { get; set; } = "";
public string SenderEmail { get; set; } = "";
@@ -47,6 +48,7 @@ public static class CoverLetterDocumentSettingsJson
return new()
{
SchemaVersion = 1, ThemeId = theme.Id,
Language = value.Language?.Trim().ToLowerInvariant() is "nb" or "nb-no" ? "nb-NO" : "en",
SenderName = Text(value.SenderName, 300), SenderTitle = Text(value.SenderTitle, 300),
SenderEmail = Text(value.SenderEmail, 300), SenderPhone = Text(value.SenderPhone, 100), SenderLocation = Text(value.SenderLocation, 300),
Recipient = Text(value.Recipient, 2000), Subject = Text(value.Subject, 500), Date = Text(value.Date, 100),
+94 -9
View File
@@ -1,4 +1,7 @@
using System.Text.Json;
using System.Text;
using System.Security.Cryptography;
using System.Data;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
@@ -19,6 +22,13 @@ public sealed class AiUnavailableException : Exception
public AiUnavailableException(string message) : base(message) { }
}
public sealed class CoverLetterSourceChangedException(int inputCharacterCount, int outputCharacterCount)
: InvalidOperationException("cover_letter_source_changed")
{
public int InputCharacterCount { get; } = inputCharacterCount;
public int OutputCharacterCount { get; } = outputCharacterCount;
}
public interface IAiWorkspaceService
{
// Runs one module, stores the result as an append-only AiInteraction, and returns it. Never
@@ -27,6 +37,7 @@ public interface IAiWorkspaceService
Task<IReadOnlyList<AiInteraction>> HistoryAsync(string ownerUserId, int jobApplicationId, string? module, CancellationToken ct);
Task<AiInteraction?> GetAsync(string ownerUserId, int id, CancellationToken ct);
Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct);
Task<bool?> ValidateCoverLetterSourceAsync(string ownerUserId, int jobApplicationId, int interactionId, CancellationToken ct);
// The module keys this service supports (for the controller/UI to enumerate).
IReadOnlyList<string> Modules { get; }
@@ -35,6 +46,9 @@ public interface IAiWorkspaceService
public sealed class AiWorkspaceService : IAiWorkspaceService
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private sealed record LetterSourceBinding(int Version, string Fingerprint, string CandidateNameSha256);
private sealed record LinkedCvContext(string Name, string Text, bool HasCareerEvidence,
int VariantId, int VariantVersion, string SettingsJson, int ProfileId, int ProfileVersion, string ProfileJson, string LongTailJson);
public IReadOnlyList<string> Modules { get; } = new[]
{
@@ -114,7 +128,7 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
var module = (req.Module ?? string.Empty).Trim().ToLowerInvariant();
if (!Modules.Contains(module)) throw new ArgumentException($"Unknown AI module '{module}'.");
var job = await _db.JobApplications.Include(j => j.Company)
var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
if (job is null) return null;
@@ -153,6 +167,10 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
};
var prompt = $"{instruction} {Guardrail}";
var candidateNameHash = Hash(candidateName);
var binding = module == "cover-letter"
? new LetterSourceBinding(1, await FingerprintAsync(ownerUserId, job, linkedCv, candidateNameHash, ct), candidateNameHash)
: null;
AiGenerationResult? generation;
try
{
@@ -170,6 +188,12 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
var actualProvider = string.IsNullOrWhiteSpace(generation?.Provider) ? provider : generation.Provider;
// Hold only the post-inference validation/history commit, never a provider call, in this transaction.
await using var transaction = module == "cover-letter" && _db.Database.IsRelational()
? await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct) : null;
if (binding is not null && binding.Fingerprint != await CurrentFingerprintAsync(ownerUserId, jobApplicationId, candidateNameHash, ct))
throw new CoverLetterSourceChangedException(prompt.Length + source.Length, result.Trim().Length);
var interaction = new AiInteraction
{
OwnerUserId = ownerUserId,
@@ -186,6 +210,7 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
model = generation?.Model,
fallbackReason = generation?.FallbackReason,
routeReason = generation?.RouteReason,
coverLetterSource = binding,
},
}, Json),
InputCharacterCount = prompt.Length + source.Length,
@@ -195,6 +220,7 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
};
_db.AiInteractions.Add(interaction);
await _db.SaveChangesAsync(ct);
if (transaction is not null) await transaction.CommitAsync(ct);
return interaction;
}
@@ -210,6 +236,63 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
public Task<AiInteraction?> GetAsync(string ownerUserId, int id, CancellationToken ct) =>
_db.AiInteractions.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct);
public async Task<bool?> ValidateCoverLetterSourceAsync(string ownerUserId, int jobApplicationId, int interactionId, CancellationToken ct)
{
var row = await _db.AiInteractions.AsNoTracking().FirstOrDefaultAsync(item => item.Id == interactionId
&& item.OwnerUserId == ownerUserId && item.JobApplicationId == jobApplicationId && item.Module == "cover-letter", ct);
if (row is null) return null;
if (!await _db.JobApplications.AsNoTracking().AnyAsync(job => job.Id == jobApplicationId && job.OwnerUserId == ownerUserId, ct)) return null;
LetterSourceBinding? binding;
try
{
using var json = JsonDocument.Parse(row.ResultJson);
if (json.RootElement.ValueKind != JsonValueKind.Object || !json.RootElement.TryGetProperty("meta", out var meta) || meta.ValueKind != JsonValueKind.Object
|| !meta.TryGetProperty("coverLetterSource", out var source) || source.ValueKind != JsonValueKind.Object) return false;
binding = source.Deserialize<LetterSourceBinding>(Json);
}
catch (JsonException) { return false; }
if (binding is not { Version: 1 } || !IsHash(binding.Fingerprint) || !IsHash(binding.CandidateNameSha256)) return false;
return binding.Fingerprint == await CurrentFingerprintAsync(ownerUserId, jobApplicationId, binding.CandidateNameSha256, ct);
}
private static bool IsHash(string? value) => value is { Length: 64 } && value.All(Uri.IsHexDigit);
private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private async Task<string?> CurrentFingerprintAsync(string ownerUserId, int jobApplicationId, string candidateNameHash, CancellationToken ct)
{
var job = await _db.JobApplications.AsNoTracking().Include(item => item.Company)
.FirstOrDefaultAsync(item => item.Id == jobApplicationId && item.OwnerUserId == ownerUserId, ct);
if (job is null) return null;
var linked = await BuildLinkedCvContextAsync(ownerUserId, jobApplicationId, string.Empty, ct);
return await FingerprintAsync(ownerUserId, job, linked, candidateNameHash, ct, refreshCandidateName: true);
}
private async Task<string> FingerprintAsync(string ownerUserId, JobApplication job, LinkedCvContext? linked,
string candidateNameHash, CancellationToken ct, bool refreshCandidateName = false)
{
var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, ct);
if (refreshCandidateName && user is not null)
{
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(value => !string.IsNullOrWhiteSpace(value)));
if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim();
if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim();
candidateNameHash = Hash(name ?? string.Empty);
}
var attachments = await _db.Attachments.AsNoTracking().Where(item => item.JobApplicationId == job.Id)
.OrderBy(item => item.Id).Select(item => new
{
item.Id, item.FileName, item.FilePath, item.FileSize, item.FileType, item.UploadDate, item.Purpose, item.UseForAi,
}).ToListAsync(ct);
// Only the digest is stored; raw profile, document paths and identity stay out of AI metadata.
return Hash(JsonSerializer.Serialize(new
{
schemaVersion = 1, ownerUserId, job.Id, job.Version, job.CompanyId, jobContext = BuildJobContext(job),
job.CoverLetterText, job.CoverLetterDocumentSettingsJson, candidateNameHash, linked,
identity = user is null ? null : new { user.FirstName, user.LastName, user.DisplayName, user.UserName, user.ProfileCvText, user.ProfileCvStructureJson, user.CurrentCvProfileVersion },
attachments,
}, Json));
}
public async Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct)
{
var row = await GetAsync(ownerUserId, id, ct);
@@ -242,22 +325,24 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
: "generate";
}
private async Task<(string Name, string Text, bool HasCareerEvidence)?> BuildLinkedCvContextAsync(string ownerUserId, int jobApplicationId, string candidateName, CancellationToken ct)
private async Task<LinkedCvContext?> BuildLinkedCvContextAsync(string ownerUserId, int jobApplicationId, string candidateName, CancellationToken ct)
{
var query = _db.CvVariants.AsNoTracking()
.Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId);
var variant = _db.Database.IsSqlite()
? (await query.ToListAsync(ct)).MaxBy(v => v.UpdatedAtUtc)
: await query.OrderByDescending(v => v.UpdatedAtUtc).FirstOrDefaultAsync(ct);
? (await query.ToListAsync(ct)).OrderByDescending(v => v.UpdatedAtUtc).ThenByDescending(v => v.Id).FirstOrDefault()
: await query.OrderByDescending(v => v.UpdatedAtUtc).ThenByDescending(v => v.Id).FirstOrDefaultAsync(ct);
if (variant is null) return null;
var profileJson = await _db.CareerProfiles.AsNoTracking()
var career = await _db.CareerProfiles.AsNoTracking()
.Where(profile => profile.OwnerUserId == ownerUserId)
.Select(profile => profile.ProfileJson)
.FirstOrDefaultAsync(ct);
if (string.IsNullOrWhiteSpace(profileJson)) return (variant.Name, "(the linked CV has no content yet)", false);
LinkedCvContext Snapshot(string text, bool hasEvidence) => new(variant.Name, text, hasEvidence,
variant.Id, variant.Version, variant.SettingsJson, career?.Id ?? 0, career?.Version ?? 0,
career?.ProfileJson ?? string.Empty, career?.LongTailJson ?? string.Empty);
if (string.IsNullOrWhiteSpace(career?.ProfileJson)) return Snapshot("(the linked CV has no content yet)", false);
var profile = StructuredCvProfileJson.DeserializePersisted(profileJson);
var profile = StructuredCvProfileJson.DeserializePersisted(career.ProfileJson);
var model = CvVariantResolver.Build(profile, CvVariantSettingsJson.Deserialize(variant.SettingsJson), candidateName, null);
// Headers, identity, the advert and a prior draft do not establish candidate experience.
var hasCareerEvidence = model.Sections.Any(section => section.Bullets.Any(HasText)
@@ -275,7 +360,7 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
string.Join(" | ", new[] { entry.Title, entry.Subtitle, entry.Meta, string.Join("; ", entry.Bullets), string.Join(", ", entry.Tags) }
.Where(value => !string.IsNullOrWhiteSpace(value)))));
}
return (variant.Name, string.Join("\n", lines.Where(line => !string.IsNullOrWhiteSpace(line))), hasCareerEvidence);
return Snapshot(string.Join("\n", lines.Where(line => !string.IsNullOrWhiteSpace(line))), hasCareerEvidence);
}
private static bool HasText(string? value) => !string.IsNullOrWhiteSpace(value);
@@ -32,7 +32,7 @@ public sealed class CoverLetterDocumentRenderer
if (list) body.Append("</ul>");
var contact = string.Join(" · ", new[] { s.SenderEmail, s.SenderPhone, s.SenderLocation }.Where(v => !string.IsNullOrWhiteSpace(v)).Select(E));
var html = $$"""
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<!DOCTYPE html><html lang="{{E(s.Language)}}"><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; font-src data:; base-uri 'none'">
<title>{{E(s.Subject)}} — {{E(s.SenderName)}}</title><style>
{{CvDocumentFonts.Embed(font)}}