using System.Data; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; public sealed class CvAiSuggestionException(int status, string code) : Exception(code) { public int Status { get; } = status; public string Code { get; } = code; } public sealed record CvAiProposal(string Original, string Result, string ProposalToken); // Suggestions are capabilities for one owned, saved source state, not client-supplied facts. // No proposal table or alternative revision/history model is needed: the existing protected // key ring binds the reviewed value; the existing versioned save commits its scoped application. public sealed class CvAiSuggestionService( JobTrackerContext db, ICareerProfileService career, ICvVariantService variants, ISummarizerService ai, IDataProtectionProvider protection) { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly IDataProtector protector = protection.CreateProtector("JobTracker.CvAiProposal.v1"); private static readonly HashSet Actions = new(StringComparer.Ordinal) { "improve", "professional", "shorten", "expand", "grammar", "impact", "ats", "bullets", "summary", "rewrite", "tailor", "document-translate", "document-improve", "document-grammar" }; private sealed record Block(string Id, string? Title, string? Subtitle, List Items); private sealed record Snapshot(CvVariant Variant, CvVariantSettings Settings, List Blocks, List EditableBlocks, string Fingerprint); private sealed record Ticket(string Owner, int VariantId, int Version, string Fingerprint, string? TargetId, string Action, string? Context, string Original, string Result); public async Task GenerateAsync(string owner, int id, int version, string? targetId, string action, string? inputText, string? context, string instruction, CancellationToken ct) { action = action.Trim().ToLowerInvariant(); if (!Actions.Contains(action)) throw Error(400, "cv_ai_action_invalid"); var document = action.StartsWith("document-", StringComparison.Ordinal); if (document != (targetId is null)) throw Error(400, "cv_ai_scope_invalid"); var snapshot = await SnapshotAsync(owner, id, ct); RequireCurrent(snapshot, version, null); var source = Source(targetId is null ? snapshot.Blocks : snapshot.EditableBlocks, targetId); if (inputText is not null && !string.Equals(ContentLines(inputText), ContentLines(source.Text), StringComparison.Ordinal)) throw Error(409, "cv_ai_source_changed"); if (string.IsNullOrWhiteSpace(source.Text)) throw Error(400, "cv_ai_source_empty"); if (source.Text.Length > 20_000 || source.Evidence.Length > 20_000 || context?.Length > 4_000) throw Error(400, "cv_ai_source_too_large"); var generated = document ? await ai.GenerateSectionWithMetadataAsync(instruction, source.Text, AiGenerationOutputMode.CvBlocksJson, 16_000, 50, ct) : await ai.GenerateSectionWithMetadataAsync(instruction, source.Text, 1200, 300, ct); if (string.IsNullOrWhiteSpace(generated?.Text)) throw Error(502, "cv_ai_unavailable"); var result = generated.Text.Trim(); ValidateShape(snapshot.Blocks, targetId, result); await RequireGrounded(source.Evidence, result, context, ct); var current = await SnapshotAsync(owner, id, ct); RequireCurrent(current, version, snapshot.Fingerprint); var ticket = new Ticket(owner, id, version, snapshot.Fingerprint, targetId, action, context, source.Text, result); return new(source.Text, result, protector.Protect(JsonSerializer.Serialize(ticket, Json))); } public async Task ApplyAsync(string owner, int id, string token, string? editedResult, CancellationToken ct) { if (token.Length > 200_000) throw Error(400, "cv_ai_proposal_invalid"); Ticket ticket; try { ticket = JsonSerializer.Deserialize(protector.Unprotect(token), Json) ?? throw new JsonException(); } catch (Exception error) when (error is CryptographicException or JsonException or ArgumentException) { throw Error(400, "cv_ai_proposal_invalid"); } if (ticket.Owner != owner || ticket.VariantId != id) throw Error(404, "cv_ai_proposal_unavailable"); var snapshot = await SnapshotAsync(owner, id, ct); RequireCurrent(snapshot, ticket.Version, ticket.Fingerprint); var result = editedResult?.Trim() ?? ticket.Result; ValidateShape(snapshot.Blocks, ticket.TargetId, result); // Editing a reviewed proposal is allowed, but the edited value is untrusted too. if (!string.Equals(result, ticket.Result, StringComparison.Ordinal)) await RequireGrounded(Source(ticket.TargetId is null ? snapshot.Blocks : snapshot.EditableBlocks, ticket.TargetId).Evidence, result, ticket.Context, ct); // Keep the final source read and ordinary versioned save in one serializable transaction. // This also protects canonical changes, which do not increment a CV variant's Version. db.ChangeTracker.Clear(); await using var transaction = db.Database.IsRelational() ? await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct) : null; snapshot = await ReadSnapshotAsync(owner, id, ct); RequireCurrent(snapshot, ticket.Version, ticket.Fingerprint); ApplyToSettings(snapshot.Settings, ticket.TargetId is null ? snapshot.Blocks : snapshot.EditableBlocks, ticket.TargetId, result); var saved = await variants.SaveAsync(owner, id, null, snapshot.Settings, $"ai:{ticket.Action}", ticket.Version, ct) ?? throw Error(404, "cv_ai_proposal_unavailable"); if (transaction is not null) await transaction.CommitAsync(ct); return saved; } private async Task RequireGrounded(string evidence, string proposal, string? context, CancellationToken ct) { if (proposal.Length > 20_000) throw Error(400, "cv_ai_proposal_invalid"); var assessment = await ai.EvaluateCvRewriteAsync(evidence, proposal, context, ct); if (assessment is null || !CvAiGrounding.IsSupported(evidence, proposal, assessment.Text)) throw Error(422, "cv_ai_grounding_unproven"); } private async Task SnapshotAsync(string owner, int id, CancellationToken ct) { db.ChangeTracker.Clear(); await using var transaction = db.Database.IsRelational() ? await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct) : null; var snapshot = await ReadSnapshotAsync(owner, id, ct); if (transaction is not null) await transaction.CommitAsync(ct); return snapshot; } private async Task ReadSnapshotAsync(string owner, int id, CancellationToken ct) { var variant = await variants.GetAsync(owner, id, ct) ?? throw Error(404, "cv_ai_proposal_unavailable"); // The existing read-only owner path avoids legacy backfill writes while generating proposals. var profile = await career.LoadStructuredForOwnerAsync(owner, ct); var settings = CvVariantSettingsJson.Deserialize(variant.SettingsJson); var blocks = Blocks(CvVariantResolver.Build(profile, settings, string.Empty, null)); // Omitted content remains editable, including existing section-level AI actions. Only // its explicitly selected text is evidence; document actions still exclude omitted rows. var editableSettings = CvVariantSettingsJson.Deserialize(CvVariantSettingsJson.Serialize(settings)); foreach (var section in editableSettings.Sections) section.Hidden = false; foreach (var item in editableSettings.Overrides.Values) item.Hidden = false; foreach (var section in editableSettings.CustomSections) section.Hidden = false; editableSettings.SkillsStyle = null; editableSettings.SkillGroups = null; var editableBlocks = Blocks(CvVariantResolver.Build(profile, editableSettings, string.Empty, null)); var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( StructuredCvProfileJson.SerializePersisted(profile) + "\n" + CvVariantSettingsJson.Serialize(settings)))); return new(variant, settings, blocks, editableBlocks, fingerprint); } private static List Blocks(CvRenderModel model) { var blocks = new List(); if (!string.IsNullOrWhiteSpace(model.Headline)) blocks.Add(new("headline", null, null, [model.Headline])); foreach (var section in model.Sections) { if (section.Key.StartsWith("custom:", StringComparison.Ordinal)) blocks.Add(new(section.Key, section.Title, null, section.Bullets)); else if (section.Kind == "entries") blocks.AddRange(section.Entries.Where(entry => entry.Key is not null) .Select(entry => new Block($"entry:{section.Key}:{entry.Key}", entry.Title, entry.Subtitle, entry.Bullets))); else if (section.Kind is "bullets" or "tags") blocks.Add(new($"section:{section.Key}", section.Title, null, section.Kind == "tags" ? section.Tags : section.Bullets)); } return blocks.Where(block => block.Items.Count > 0 || !string.IsNullOrWhiteSpace(block.Title)).ToList(); } private static (string Text, string Evidence) Source(List blocks, string? targetId) { if (targetId is null) { var text = JsonSerializer.Serialize(blocks, Json); return (text, text); } var block = blocks.SingleOrDefault(block => block.Id == targetId) ?? throw Error(400, "cv_ai_scope_invalid"); return (string.Join(targetId.StartsWith("custom:", StringComparison.Ordinal) ? "\n\n" : "\n", block.Items), JsonSerializer.Serialize(new[] { block }, Json)); } private static void RequireCurrent(Snapshot snapshot, int version, string? fingerprint) { if (snapshot.Variant.Version != version || fingerprint is not null && snapshot.Fingerprint != fingerprint) throw Error(409, "cv_ai_source_changed"); } private static List ParseBlocks(List originals, string result) { try { using var document = JsonDocument.Parse(result); if (document.RootElement.ValueKind != JsonValueKind.Array) throw new JsonException(); var parsed = new List(); foreach (var value in document.RootElement.EnumerateArray()) { if (value.ValueKind != JsonValueKind.Object || value.EnumerateObject().Select(property => property.Name).Distinct().Count() != value.EnumerateObject().Count() || value.EnumerateObject().Any(property => property.Name is not ("id" or "title" or "subtitle" or "items"))) throw new JsonException(); var id = value.GetProperty("id").GetString(); var original = originals.SingleOrDefault(block => block.Id == id) ?? throw new JsonException(); string? Field(string name, string? expected) { if (!value.TryGetProperty(name, out var field)) return expected is null ? null : throw new JsonException(); if (field.ValueKind != JsonValueKind.String || expected is null) throw new JsonException(); return field.GetString(); } var items = value.GetProperty("items"); if (items.ValueKind != JsonValueKind.Array || items.EnumerateArray().Any(item => item.ValueKind != JsonValueKind.String)) throw new JsonException(); if (id == "headline" && items.GetArrayLength() != 1) throw new JsonException(); parsed.Add(new(id!, Field("title", original.Title), Field("subtitle", original.Subtitle), items.EnumerateArray().Select(item => item.GetString()!).ToList())); } if (parsed.Count != originals.Count || parsed.Select(block => block.Id).Distinct().Count() != parsed.Count) throw new JsonException(); return parsed; } catch (Exception error) when (error is JsonException or InvalidOperationException or KeyNotFoundException) { throw Error(422, "cv_ai_proposal_invalid"); } } private static void ValidateShape(List originals, string? targetId, string result) { if (string.IsNullOrWhiteSpace(result) || result.Length > 20_000) throw Error(422, "cv_ai_proposal_invalid"); if (targetId is null) _ = ParseBlocks(originals, result); } private static void ApplyToSettings(CvVariantSettings settings, List originals, string? targetId, string result) { var changes = targetId is null ? ParseBlocks(originals, result) : [originals.Single(block => block.Id == targetId) with { Items = result.Split('\n').ToList() }]; foreach (var block in changes) { if (block.Id == "headline") settings.Headline = targetId is null ? block.Items.Single() : result; else if (block.Id.StartsWith("entry:", StringComparison.Ordinal)) { var key = block.Id[(block.Id.IndexOf(':', 6) + 1)..]; if (!settings.Overrides.TryGetValue(key, out var item)) settings.Overrides[key] = item = new(); if (targetId is null) { item.Title = block.Title; item.Subtitle = block.Subtitle; } item.Bullets = block.Items; } else if (block.Id.StartsWith("custom:", StringComparison.Ordinal)) { var custom = settings.CustomSections.Single(section => $"custom:{section.Key}" == block.Id); if (targetId is null) custom.Title = block.Title; custom.Items = targetId is not null && custom.ContentType == "paragraphs" ? System.Text.RegularExpressions.Regex.Split(result, @"\n\s*\n").ToList() : block.Items; } else { var key = block.Id[8..]; var section = settings.Sections.FirstOrDefault(section => section.Key == key); if (section is null) { section = new() { Key = key }; settings.Sections.Add(section); } if (targetId is null) section.Title = block.Title; section.Items = block.Items; } } } private static CvAiSuggestionException Error(int status, string code) => new(status, code); private static string ContentLines(string value) => string.Join("\n", value.Split('\n') .Select(line => line.Trim()).Where(line => line.Length > 0)); }