From 89792445766bfde797f85fb5b4e600e51cbd0909 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 26 Oct 2026 03:44:37 +0100 Subject: [PATCH] fix(cv): bind letter proposals to current sources and preserve document choices --- .../CoverLetterAiSourceTests.cs | 146 +++++++++++ JobTrackerApi.Tests/AiWorkspaceSourceTests.cs | 246 ++++++++++++++++++ JobTrackerApi.Tests/AiWorkspaceTests.cs | 5 +- .../CoverLetterDocumentContractTests.cs | 23 ++ .../CoverLetterDocumentLanguageTests.cs | 42 +++ .../Controllers/AiWorkspaceController.cs | 23 ++ .../Models/CoverLetterDocumentSettings.cs | 2 + JobTrackerApi/Services/AiWorkspaceService.cs | 103 +++++++- .../Services/CoverLetterDocumentRenderer.cs | 2 +- docs/cv-redesign/AI.md | 14 +- docs/cv-redesign/CHECKPOINT_IMPLEMENTATION.md | 8 + docs/cv-redesign/CV_IMPORT_HANDOFF.md | 2 + docs/cv-redesign/TODO.md | 4 + .../cover-letter-document-fidelity.spec.ts | 21 +- job-tracker-ui/src/aiWorkspace.ts | 1 + .../src/application-assets.test.tsx | 78 +++++- job-tracker-ui/src/applicationWorkspace.ts | 1 + .../src/components/ApplicationAssets.tsx | 45 +++- .../src/components/CoverLetterDocument.tsx | 24 +- .../src/cover-letter-document.test.tsx | 52 +++- job-tracker-ui/src/i18n/translations.ts | 6 + 21 files changed, 816 insertions(+), 32 deletions(-) create mode 100644 JobTrackerApi.MariaDb.Tests/CoverLetterAiSourceTests.cs create mode 100644 JobTrackerApi.Tests/AiWorkspaceSourceTests.cs create mode 100644 JobTrackerApi.Tests/CoverLetterDocumentLanguageTests.cs diff --git a/JobTrackerApi.MariaDb.Tests/CoverLetterAiSourceTests.cs b/JobTrackerApi.MariaDb.Tests/CoverLetterAiSourceTests.cs new file mode 100644 index 0000000..d71fa48 --- /dev/null +++ b/JobTrackerApi.MariaDb.Tests/CoverLetterAiSourceTests.cs @@ -0,0 +1,146 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.MariaDb.Tests; + +public sealed class CoverLetterAiSourceTests +{ + private const string Owner = "rd21-owner"; + + private static async Task SeedAsync(JobTrackerContext db) + { + var job = new JobApplication + { + OwnerUserId = Owner, Company = new Company { OwnerUserId = Owner, Name = "Source-bound employer" }, + JobTitle = "Engineer", Description = "Build C# services", Status = "Prospect", CoverLetterText = "Approved manual letter", + }; + db.Users.Add(new() { Id = Owner, DisplayName = "Ada" }); + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + db.CareerProfiles.Add(new() + { + OwnerUserId = Owner, Version = 1, + ProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "C#" } }), + }); + db.CvVariants.Add(new() + { + OwnerUserId = Owner, JobApplicationId = job.Id, Name = "Owned linked CV", Version = 1, + PublicSlug = Guid.NewGuid().ToString("N"), SettingsJson = CvVariantSettingsJson.Serialize(new()), + CreatedAtUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + return job.Id; + } + + private static AiWorkspaceService Service(JobTrackerContext db, Func? beforeResult = null) + { + var ai = new Mock(); + ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async () => + { + // Provider waits must not hold the later serializable validation/history transaction. + Assert.Null(db.Database.CurrentTransaction); + if (beforeResult is not null) await beforeResult(); + return new AiGenerationResult("Verified source-bound suggestion.", "local-test", "stub", null, "local_primary"); + }); + return new(db, ai.Object); + } + + private static Task Generate(AiWorkspaceService service, int jobId) => + service.GenerateAsync(Owner, jobId, "unused fallback", "Ada", new("cover-letter", "professional", null), "local-test", default); + + private static async Task AssertLetterUntouched(JobTrackerContext db, int jobId) + { + var job = await db.JobApplications.AsNoTracking().SingleAsync(value => value.Id == jobId); + Assert.Equal("Approved manual letter", job.CoverLetterText); + Assert.Equal(1, job.Version); + Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync()); + } + + [Fact] + public async Task Bound_generation_commits_on_mariadb_and_read_only_validation_accepts_current_source() + { + await using var database = await MariaDbDatabase.CreateAsync(); + await using var db = database.Context(); + var jobId = await SeedAsync(db); + var service = Service(db); + var result = await Generate(service, jobId); + Assert.NotNull(result); + Assert.Null(db.Database.CurrentTransaction); + Assert.True(await service.ValidateCoverLetterSourceAsync(Owner, jobId, result.Id, default)); + await using var fresh = database.Context(); + Assert.True(await Service(fresh).ValidateCoverLetterSourceAsync(Owner, jobId, result.Id, default)); + Assert.Single(await fresh.AiInteractions.AsNoTracking().ToListAsync()); + Assert.Equal(1, (await fresh.CareerProfiles.AsNoTracking().SingleAsync()).Version); + Assert.Equal(1, (await fresh.CvVariants.AsNoTracking().SingleAsync()).Version); + await AssertLetterUntouched(fresh, jobId); + Assert.False(db.ChangeTracker.HasChanges()); + } + + [Fact] + public async Task Separate_context_profile_edit_during_inference_is_rejected_without_stale_history_on_mariadb() + { + await using var database = await MariaDbDatabase.CreateAsync(); + await using var db = database.Context(); + var jobId = await SeedAsync(db); + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var service = Service(db, () => { reached.TrySetResult(); return release.Task; }); + var pending = Generate(service, jobId); + await reached.Task.WaitAsync(TimeSpan.FromSeconds(15)); + try + { + await using var writer = database.Context(); + var profile = await writer.CareerProfiles.SingleAsync(); + profile.Version++; + profile.ProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "Changed qualification" } }); + await writer.SaveChangesAsync(); + } + finally { release.TrySetResult(); } + var stale = await Assert.ThrowsAsync(() => pending); + Assert.Equal("cover_letter_source_changed", stale.Message); + Assert.True(stale.InputCharacterCount > 0); + Assert.True(stale.OutputCharacterCount > 0); + Assert.Null(db.Database.CurrentTransaction); + await using var fresh = database.Context(); + Assert.Empty(await fresh.AiInteractions.AsNoTracking().ToListAsync()); + Assert.Equal(2, (await fresh.CareerProfiles.AsNoTracking().SingleAsync()).Version); + await AssertLetterUntouched(fresh, jobId); + } + + [Fact] + public async Task Separate_context_cv_and_attachment_changes_invalidate_pre_apply_source_on_mariadb() + { + await using var database = await MariaDbDatabase.CreateAsync(); + await using var db = database.Context(); + var jobId = await SeedAsync(db); + var service = Service(db); + var beforeCv = await Generate(service, jobId); + Assert.True(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeCv!.Id, default)); + await using (var writer = database.Context()) + { + var cv = await writer.CvVariants.SingleAsync(); + cv.Version++; + cv.SettingsJson = CvVariantSettingsJson.Serialize(new() { Headline = "Reviewed CV lens changed" }); + await writer.SaveChangesAsync(); + } + Assert.False(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeCv.Id, default)); + var beforeAttachment = await Generate(service, jobId); + Assert.True(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeAttachment!.Id, default)); + await using (var writer = database.Context()) + { + writer.Attachments.Add(new() { JobApplicationId = jobId, FileName = "new-source.txt", FilePath = "synthetic-managed-path", FileSize = 17 }); + await writer.SaveChangesAsync(); + } + Assert.False(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeAttachment.Id, default)); + await using var fresh = database.Context(); + Assert.Equal(2, await fresh.AiInteractions.CountAsync()); + Assert.Equal(1, (await fresh.CareerProfiles.AsNoTracking().SingleAsync()).Version); + await AssertLetterUntouched(fresh, jobId); + } +} diff --git a/JobTrackerApi.Tests/AiWorkspaceSourceTests.cs b/JobTrackerApi.Tests/AiWorkspaceSourceTests.cs new file mode 100644 index 0000000..f8b5bf4 --- /dev/null +++ b/JobTrackerApi.Tests/AiWorkspaceSourceTests.cs @@ -0,0 +1,246 @@ +using System.Text.Json; +using System.Security.Claims; +using System.Reflection; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using JobTrackerApi.Tests.TestSupport; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed partial class AiWorkspaceTests +{ + [Theory] + [InlineData("[]")] + [InlineData("\"legacy\"")] + [InlineData("null")] + public async Task Non_object_legacy_AI_result_fails_closed_without_throwing(string json) + { + var (db, service, _) = New("user-1"); + await using var dispose = db; + var jobId = await SeedJobAsync(db, "user-1"); + var row = new AiInteraction { OwnerUserId = "user-1", JobApplicationId = jobId, Module = "cover-letter", ResultJson = json }; + db.Add(row); + await db.SaveChangesAsync(); + Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, row.Id, default)); + } + + [Fact] + public async Task Stale_generation_HTTP_conflict_finalizes_actual_consumed_usage_without_recording_stale_history() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = new JobTrackerContext(new DbContextOptionsBuilder().UseSqlite(connection).Options, + Mock.Of(user => user.UserId == "user-1")); + await db.Database.EnsureCreatedAsync(); + var user = new ApplicationUser { Id = "user-1", DisplayName = "Ada" }; + db.Users.Add(user); + var jobId = await SeedJobAsync(db, user.Id); + await SeedLinkedProfileAsync(db, jobId); + var users = TestHostFactory.CreateUserManager(user); + users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(user); + users.Setup(manager => manager.GetRolesAsync(It.IsAny())).ReturnsAsync(new[] { "Premium" }); + var ai = new FakeAi + { + BeforeReturn = async () => + { + await using var changed = Independent(db); + await ChangeSource(changed, jobId, "profile-version"); + }, + }; + var controller = new AiWorkspaceController(users.Object, new AiWorkspaceService(db, ai), new ConfigurationBuilder().Build(), db); + var response = await controller.Generate(jobId, new("cover-letter", null, null), default); + var conflict = Assert.IsType(response.Result); + Assert.Contains("cover_letter_source_changed", JsonSerializer.Serialize(conflict.Value)); + var usage = await db.AiUsageRecords.AsNoTracking().SingleAsync(); + Assert.Equal(1, usage.CallCount); + Assert.Equal(ai.Next!.Trim().Length, usage.OutputCharacterCount); + Assert.NotEqual(16_000, usage.InputCharacterCount); + Assert.True(usage.InputCharacterCount > 0); + Assert.Equal(AiWorkspaceService.EstimateTokens(usage.InputCharacterCount + usage.OutputCharacterCount), usage.EstimatedTokenCount); + Assert.Empty(await db.AiInteractions.AsNoTracking().ToListAsync()); + Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync()); + Assert.Null((await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText); + Assert.Equal(1, (await db.CareerProfiles.AsNoTracking().SingleAsync()).Version); + } + + [Fact] + public async Task Validator_HTTP_contract_is_read_only_Pro_authenticated_and_scoped_to_owner_job_module() + { + Assert.Equal("local", typeof(AiWorkspaceController).GetCustomAttribute()!.AuthenticationSchemes); + Assert.Equal(ProEntitlement.Policy, typeof(AiWorkspaceController).GetMethod(nameof(AiWorkspaceController.ValidateSource))!.GetCustomAttribute()!.Policy); + var (db, service, _) = New("user-1"); + await using var dispose = db; + var jobId = await SeedJobAsync(db, "user-1"); + await SeedLinkedProfileAsync(db, jobId); + var generated = await service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default); + var user = new ApplicationUser { Id = "user-1" }; + var users = TestHostFactory.CreateUserManager(user); + users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(user); + var controller = new AiWorkspaceController(users.Object, service, new ConfigurationBuilder().Build(), db); + Assert.IsType(await controller.ValidateSource(jobId, generated!.Id, default)); + Assert.IsType(await controller.ValidateSource(jobId + 1, generated.Id, default)); + Assert.IsType(await controller.ValidateSource(jobId, int.MaxValue, default)); + await using (var changed = Independent(db)) await ChangeSource(changed, jobId, "cv-settings"); + var stale = Assert.IsType(await controller.ValidateSource(jobId, generated.Id, default)); + Assert.Contains("cover_letter_source_changed", JsonSerializer.Serialize(stale.Value)); + users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(new ApplicationUser { Id = "foreign" }); + Assert.IsType(await controller.ValidateSource(jobId, generated.Id, default)); + users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); + Assert.IsType(await controller.ValidateSource(jobId, generated.Id, default)); + Assert.Empty(await db.AiUsageRecords.AsNoTracking().ToListAsync()); + Assert.Single(await db.AiInteractions.AsNoTracking().ToListAsync()); + Assert.False(db.ChangeTracker.HasChanges()); + } + + private static JobTrackerContext Independent(JobTrackerContext db) => new( + (DbContextOptions)db.GetService(), Mock.Of(user => user.UserId == "user-1")); + + private static async Task ChangeSource(JobTrackerContext db, int jobId, string change) + { + switch (change) + { + case "profile-version": (await db.CareerProfiles.SingleAsync()).Version++; break; + case "profile-content": (await db.CareerProfiles.SingleAsync()).ProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "A different qualification" } }); break; + case "profile-long-tail": (await db.CareerProfiles.SingleAsync()).LongTailJson = "{\"summary\":[\"Changed source\"]}"; break; + case "cv-version": (await db.CvVariants.SingleAsync()).Version++; break; + case "cv-settings": (await db.CvVariants.SingleAsync()).SettingsJson = CvVariantSettingsJson.Serialize(new() { Headline = "Changed CV lens" }); break; + case "cv-detach": (await db.CvVariants.SingleAsync()).JobApplicationId = null; break; + case "cv-attach": + db.CvVariants.Add(new() { OwnerUserId = "user-1", JobApplicationId = jobId, Name = "New CV", PublicSlug = Guid.NewGuid().ToString("N"), UpdatedAtUtc = DateTimeOffset.UtcNow.AddMinutes(1), SettingsJson = "{}" }); break; + case "attachment-add": + db.Attachments.Add(new() { JobApplicationId = jobId, FileName = "new.txt", FilePath = "new-managed-path", FileSize = 10 }); break; + case "attachment-change": (await db.Attachments.SingleAsync()).UseForAi = false; break; + case "attachment-remove": db.Attachments.Remove(await db.Attachments.SingleAsync()); break; + case "job": (await db.JobApplications.SingleAsync()).Description = "Different role requirements"; break; + case "company": (await db.Companies.SingleAsync()).Name = "Different employer"; break; + case "candidate-name": (await db.Users.SingleAsync()).DisplayName = "Babbage"; break; + case "letter": (await db.JobApplications.SingleAsync()).CoverLetterText = "New saved draft"; break; + default: throw new ArgumentException(change); + } + await db.SaveChangesAsync(); + } + + [Theory] + [InlineData("profile-version")] + [InlineData("profile-content")] + [InlineData("cv-settings")] + [InlineData("cv-detach")] + [InlineData("attachment-add")] + [InlineData("job")] + [InlineData("candidate-name")] + [InlineData("letter")] + public async Task Held_letter_generation_refuses_changed_source_without_history_or_letter_writes(string change) + { + var (db, service, ai) = New("user-1"); + await using var dispose = db; + var jobId = await SeedJobAsync(db, "user-1"); + await SeedLinkedProfileAsync(db, jobId); + if (change == "candidate-name") { db.Users.Add(new() { Id = "user-1", DisplayName = "Ada" }); await db.SaveChangesAsync(); } + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ai.BeforeReturn = () => { reached.TrySetResult(); return release.Task; }; + var pending = service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default); + await reached.Task.WaitAsync(TimeSpan.FromSeconds(10)); + try + { + await using var writer = Independent(db); + await ChangeSource(writer, jobId, change); + } + finally { release.TrySetResult(); } + var error = await Assert.ThrowsAsync(() => pending); + Assert.Equal("cover_letter_source_changed", error.Message); + Assert.Empty(await db.AiInteractions.AsNoTracking().ToListAsync()); + Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync()); + Assert.Equal(change == "letter" ? "New saved draft" : null, (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText); + Assert.Equal(1, ai.Calls); + } + + [Theory] + [InlineData("profile-version")] + [InlineData("profile-content")] + [InlineData("profile-long-tail")] + [InlineData("cv-version")] + [InlineData("cv-settings")] + [InlineData("cv-detach")] + [InlineData("cv-attach")] + [InlineData("attachment-add")] + [InlineData("attachment-change")] + [InlineData("attachment-remove")] + [InlineData("job")] + [InlineData("company")] + [InlineData("candidate-name")] + [InlineData("letter")] + public async Task Pre_apply_validation_reloads_source_and_rejects_post_generation_changes(string change) + { + var (db, service, _) = New("user-1"); + await using var dispose = db; + var jobId = await SeedJobAsync(db, "user-1"); + await SeedLinkedProfileAsync(db, jobId); + if (change == "candidate-name") { db.Users.Add(new() { Id = "user-1", DisplayName = "Ada" }); await db.SaveChangesAsync(); } + db.Attachments.Add(new() { JobApplicationId = jobId, FileName = "original.txt", FilePath = "original-managed-path", FileSize = 1 }); + await db.SaveChangesAsync(); + var generated = await service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default); + Assert.True(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated!.Id, default)); + await using (var writer = Independent(db)) await ChangeSource(writer, jobId, change); + Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default)); + Assert.Single(await db.AiInteractions.AsNoTracking().ToListAsync()); + Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync()); + Assert.Equal(change == "letter" ? "New saved draft" : null, (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText); + } + + [Fact] + public async Task Source_changed_during_prompt_assembly_is_not_rebound_to_the_newer_profile_stamp() + { + var (db, _, ai) = New("user-1"); + await using var dispose = db; + var jobId = await SeedJobAsync(db, "user-1"); + await SeedLinkedProfileAsync(db, jobId); + var intelligence = new Mock(); + intelligence.Setup(value => value.AnalyzeAsync("user-1", jobId, It.IsAny())) + .Returns(async () => + { + await using var changed = Independent(db); + await ChangeSource(changed, jobId, "profile-content"); + return (JobAnalysisDto?)null; + }); + intelligence.Setup(value => value.MatchAsync("user-1", jobId, It.IsAny())).ReturnsAsync((CareerMatchDto?)null); + var service = new AiWorkspaceService(db, ai, intelligence.Object); + await Assert.ThrowsAsync(() => service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default)); + Assert.Contains("C#", ai.LastText); + Assert.DoesNotContain("A different qualification", ai.LastText); + Assert.Empty(await db.AiInteractions.AsNoTracking().ToListAsync()); + } + + [Fact] + public async Task Source_validator_fails_closed_for_legacy_and_damaged_binding_and_scopes_owner_job_and_module() + { + var (db, service, _) = New("user-1"); + await using var dispose = db; + var jobId = await SeedJobAsync(db, "user-1"); + await SeedLinkedProfileAsync(db, jobId); + var generated = await service.GenerateAsync("user-1", jobId, "ignored", "Ada", Req("cover-letter"), "p", default); + Assert.True(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated!.Id, default)); + Assert.Null(await service.ValidateCoverLetterSourceAsync("foreign", jobId, generated.Id, default)); + Assert.Null(await service.ValidateCoverLetterSourceAsync("user-1", jobId + 1, generated.Id, default)); + Assert.Null(await service.ValidateCoverLetterSourceAsync("user-1", jobId, int.MaxValue, default)); + generated.ResultJson = JsonSerializer.Serialize(new { text = "Legacy output", meta = new { model = "old" } }); + await db.SaveChangesAsync(); + Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default)); + generated.ResultJson = "{invalid json"; + await db.SaveChangesAsync(); + Assert.False(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default)); + generated.Module = "interview"; + await db.SaveChangesAsync(); + Assert.Null(await service.ValidateCoverLetterSourceAsync("user-1", jobId, generated.Id, default)); + } +} diff --git a/JobTrackerApi.Tests/AiWorkspaceTests.cs b/JobTrackerApi.Tests/AiWorkspaceTests.cs index 4592b38..48a0916 100644 --- a/JobTrackerApi.Tests/AiWorkspaceTests.cs +++ b/JobTrackerApi.Tests/AiWorkspaceTests.cs @@ -2,12 +2,13 @@ using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; using Moq; using Xunit; namespace JobTrackerApi.Tests; -public sealed class AiWorkspaceTests +public sealed partial class AiWorkspaceTests { private sealed class FakeAi : ISummarizerService { @@ -16,6 +17,7 @@ public sealed class AiWorkspaceTests public string? LastInstruction; public string? ActualProvider; public string? FallbackReason; + public Func? BeforeReturn; // The source text the module assembled — what the prompt actually saw. public string? LastText; public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) @@ -34,6 +36,7 @@ public sealed class AiWorkspaceTests CancellationToken cancellationToken = default) { var generated = await SummarizeSectionAsync(instruction, text, maxLength, minLength); + if (BeforeReturn is not null) await BeforeReturn(); return generated is null ? null : new AiGenerationResult(generated, ActualProvider, "test-model", FallbackReason, FallbackReason is null ? "local_primary" : "external_fallback"); } diff --git a/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs b/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs index 1603257..9668a96 100644 --- a/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs +++ b/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs @@ -1,11 +1,34 @@ using JobTrackerApi.Models; using JobTrackerApi.Services; +using System.Text.Json; using Xunit; namespace JobTrackerApi.Tests; public sealed class CoverLetterDocumentContractTests { + [Theory] + [InlineData(null, "en")] + [InlineData("{}", "en")] + [InlineData("{\"senderName\":\"Legacy sender\"}", "en")] + [InlineData("{\"language\":\"en\"}", "en")] + [InlineData("{\"language\":\"nb\"}", "nb-NO")] + [InlineData("{\"language\":\"nb-NO\"}", "nb-NO")] + [InlineData("{\"language\":\" NB-no \"}", "nb-NO")] + [InlineData("{\"language\":null}", "en")] + [InlineData("{\"language\":\"fr\"}", "en")] + [InlineData("{\"language\":\"en\\\" onload=alert(1)\"}", "en")] + public void Document_language_defaults_normalizes_and_roundtrips_only_supported_values(string? json, string expected) + { + var settings = CoverLetterDocumentSettingsJson.Deserialize(json); + var normalized = CoverLetterDocumentSettingsJson.Serialize(settings); + Assert.Equal(expected, JsonDocument.Parse(normalized).RootElement.GetProperty("language").GetString()); + Assert.Equal(normalized, CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(normalized))); + var html = new CoverLetterDocumentRenderer().Render("Reviewed letter — æøå", settings).Html; + Assert.Contains($"", html); + Assert.DoesNotContain("onload=", html); + } + [Fact] public void Document_style_reuses_cv_allowlists_clamps_non_finite_values_and_escapes_all_metadata() { diff --git a/JobTrackerApi.Tests/CoverLetterDocumentLanguageTests.cs b/JobTrackerApi.Tests/CoverLetterDocumentLanguageTests.cs new file mode 100644 index 0000000..a99eaa5 --- /dev/null +++ b/JobTrackerApi.Tests/CoverLetterDocumentLanguageTests.cs @@ -0,0 +1,42 @@ +using System.Text; +using System.Text.Json; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed partial class CoverLetterWriterTests +{ + [Fact] + public async Task Letter_language_is_a_reviewed_snapshot_in_history_and_frozen_bytes() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = Context(connection); + var job = await Seed(db); + var service = Assets(db); + var norwegian = CoverLetterDocumentSettingsJson.Deserialize("{\"language\":\"nb\",\"senderName\":\"Åse\"}"); + await service.SaveCoverLetterAsync(Owner, job.Id, "Gjennomgått søknad — æøå", CoverLetterSources.Manual, null, job.Version, default, norwegian); + var settingsBytes = Encoding.UTF8.GetBytes(job.CoverLetterDocumentSettingsJson!); + var packages = new SubmittedApplicationPackageService(db, Mock.Of(), Mock.Of()); + var captured = await packages.CaptureAsync(Owner, job.Id, new("Fallback", null), default); + var frozen = await db.SubmittedApplicationPackages.AsNoTracking().SingleAsync(x => x.Id == captured!.Id); + Assert.Contains("", frozen.RenderedCoverLetterHtml); + Assert.Equal("nb-NO", JsonDocument.Parse(frozen.CoverLetterDocumentSettingsJson!).RootElement.GetProperty("language").GetString()); + var htmlBytes = Encoding.UTF8.GetBytes(frozen.RenderedCoverLetterHtml!); + + await service.SaveCoverLetterAsync(Owner, job.Id, "English revision", CoverLetterSources.Manual, null, job.Version, default, new()); + Assert.Contains("", (await service.RenderSavedCoverLetterAsync(Owner, job.Id, job.Version, default))!.Html); + await service.RestoreCoverLetterAsync(Owner, job.Id, 1, job.Version, default); + Assert.Equal(settingsBytes, Encoding.UTF8.GetBytes(job.CoverLetterDocumentSettingsJson!)); + var history = await db.CoverLetterVersions.Where(x => x.JobApplicationId == job.Id).OrderBy(x => x.Version).ToListAsync(); + Assert.Equal(new[] { "nb-NO", "en", "nb-NO" }, history.Select(x => JsonDocument.Parse(x.DocumentSettingsJson!).RootElement.GetProperty("language").GetString())); + var detail = await packages.GetAsync(Owner, job.Id, captured!.Id, default); + Assert.Equal(htmlBytes, Encoding.UTF8.GetBytes(detail!.RenderedCoverLetterHtml!)); + Assert.Equal(settingsBytes, Encoding.UTF8.GetBytes(CoverLetterDocumentSettingsJson.Serialize(detail.CoverLetterDocumentSettings))); + } +} diff --git a/JobTrackerApi/Controllers/AiWorkspaceController.cs b/JobTrackerApi/Controllers/AiWorkspaceController.cs index 1350f82..97281cf 100644 --- a/JobTrackerApi/Controllers/AiWorkspaceController.cs +++ b/JobTrackerApi/Controllers/AiWorkspaceController.cs @@ -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 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 Delete(int jobId, int id, CancellationToken ct) { diff --git a/JobTrackerApi/Models/CoverLetterDocumentSettings.cs b/JobTrackerApi/Models/CoverLetterDocumentSettings.cs index 6299580..b423999 100644 --- a/JobTrackerApi/Models/CoverLetterDocumentSettings.cs +++ b/JobTrackerApi/Models/CoverLetterDocumentSettings.cs @@ -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), diff --git a/JobTrackerApi/Services/AiWorkspaceService.cs b/JobTrackerApi/Services/AiWorkspaceService.cs index 5980e2b..92cc2d1 100644 --- a/JobTrackerApi/Services/AiWorkspaceService.cs +++ b/JobTrackerApi/Services/AiWorkspaceService.cs @@ -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> HistoryAsync(string ownerUserId, int jobApplicationId, string? module, CancellationToken ct); Task GetAsync(string ownerUserId, int id, CancellationToken ct); Task DeleteAsync(string ownerUserId, int id, CancellationToken ct); + Task ValidateCoverLetterSourceAsync(string ownerUserId, int jobApplicationId, int interactionId, CancellationToken ct); // The module keys this service supports (for the controller/UI to enumerate). IReadOnlyList 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 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 GetAsync(string ownerUserId, int id, CancellationToken ct) => _db.AiInteractions.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct); + public async Task 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(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 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 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 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 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); diff --git a/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs b/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs index 0f77fa7..fc5626f 100644 --- a/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs +++ b/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs @@ -32,7 +32,7 @@ public sealed class CoverLetterDocumentRenderer if (list) body.Append(""); var contact = string.Join(" · ", new[] { s.SenderEmail, s.SenderPhone, s.SenderLocation }.Where(v => !string.IsNullOrWhiteSpace(v)).Select(E)); var html = $$""" - + {{E(s.Subject)}} — {{E(s.SenderName)}}