fix(cv): gate AI proposals on grounding and source freshness

This commit is contained in:
cesnimda
2026-10-08 14:28:06 +02:00
parent d8d262e4d1
commit 2398ff354e
27 changed files with 2553 additions and 157 deletions
@@ -0,0 +1,253 @@
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<string> 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<string> Items);
private sealed record Snapshot(CvVariant Variant, CvVariantSettings Settings, List<Block> Blocks, List<Block> 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<CvAiProposal> 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<CvVariant> 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<Ticket>(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<Snapshot> 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<Snapshot> 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<Block> Blocks(CvRenderModel model)
{
var blocks = new List<Block>();
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<Block> 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<Block> ParseBlocks(List<Block> originals, string result)
{
try
{
using var document = JsonDocument.Parse(result);
if (document.RootElement.ValueKind != JsonValueKind.Array) throw new JsonException();
var parsed = new List<Block>();
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<Block> 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<Block> 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));
}