254 lines
15 KiB
C#
254 lines
15 KiB
C#
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));
|
|
}
|