fix(cv): bind letter proposals to current sources and preserve document choices
This commit is contained in:
@@ -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)}}
|
||||
|
||||
Reference in New Issue
Block a user