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
@@ -20,14 +20,16 @@ public sealed class CvVariantController : ControllerBase
private readonly ICvPdfExporter _pdf;
private readonly ISummarizerService _ai;
private readonly IThemedCvRenderer _renderer;
private readonly CvAiSuggestionService? _suggestions;
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai, IThemedCvRenderer? renderer = null)
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai, IThemedCvRenderer? renderer = null, CvAiSuggestionService? suggestions = null)
{
_users = users;
_variants = variants;
_pdf = pdf;
_ai = ai;
_renderer = renderer ?? new ThemedCvRenderer();
_suggestions = suggestions;
}
public sealed record VariantDto(int Id, string Name, CvVariantSettings Settings, bool IsPublic, string PublicSlug, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc);
@@ -38,8 +40,9 @@ public sealed class CvVariantController : ControllerBase
public sealed record DuplicateRequest(string? Name);
public sealed record PreviewRequest(CvVariantSettings? Settings);
public sealed record RenderDto(string ThemeId, string Html, string SuggestedFileName);
public sealed record AiAssistRequest(string? Action, string? Text, string? Context, string? Role, string? Language);
public sealed record AiAssistResult(string Original, string Result);
public sealed record AiAssistRequest(string? Action, string? Text, string? Context, string? Role, string? Language,
int? VariantId = null, int? ExpectedVersion = null, string? TargetId = null);
public sealed record AiApplyRequest(string ProposalToken, string? Result = null);
[HttpGet("themes")]
public async Task<ActionResult<IEnumerable<object>>> GetThemes()
@@ -131,6 +134,9 @@ public sealed class CvVariantController : ControllerBase
if (await _variants.GetAsync(user.Id, id, ct) is null) return NotFound();
if (request.ExpectedVersion is not > 0)
return StatusCode(StatusCodes.Status428PreconditionRequired, new { code = "cv_revision_required", message = "Reload this CV before saving." });
// AI provenance cannot be asserted through an ordinary authored-content save.
if (request.Source?.Trim().StartsWith("ai:", StringComparison.OrdinalIgnoreCase) == true)
return BadRequest(new { code = "cv_ai_proposal_required" });
var settings = CvVariantSettingsJson.Normalize(request.Settings);
if (!CvThemeCatalog.Exists(settings.ThemeId)) return BadRequest("Unknown theme.");
if (!await CanUseThemeAsync(user, settings.ThemeId))
@@ -237,25 +243,36 @@ public sealed class CvVariantController : ControllerBase
// user reviews and applies themselves. Reuses the existing provider abstraction (ISummarizerService).
[HttpPost("ai/assist")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<AiAssistResult>> AiAssist([FromBody] AiAssistRequest request, CancellationToken ct)
public async Task<ActionResult<CvAiProposal>> AiAssist([FromBody] AiAssistRequest request, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var text = (request.Text ?? string.Empty).Trim();
if (text.Length == 0) return BadRequest("Provide text to work on.");
if (text.Length > 60_000) return BadRequest("The requested CV content is too large to process in one operation.");
var instruction = BuildAiInstruction(request.Action, request.Role, request.Language, request.Context);
var isDocumentAction = (request.Action ?? string.Empty).StartsWith("document-", StringComparison.OrdinalIgnoreCase);
var result = await _ai.SummarizeSectionAsync(instruction, text, isDocumentAction ? 16_000 : 1200, isDocumentAction ? 50 : 300);
if (string.IsNullOrWhiteSpace(result))
if (request.VariantId is not > 0 || request.ExpectedVersion is not > 0)
return StatusCode(StatusCodes.Status428PreconditionRequired, new { code = "cv_revision_required" });
if (_suggestions is null) return StatusCode(503, new { code = "cv_ai_unavailable" });
try
{
var metrics = await _ai.GetMetricsAsync(ct);
return StatusCode(StatusCodes.Status502BadGateway, metrics.Healthy
? "The AI service returned no usable text."
: "The AI service is unavailable right now.");
return Ok(await _suggestions.GenerateAsync(user.Id, request.VariantId.Value, request.ExpectedVersion.Value,
request.TargetId, request.Action ?? "improve", request.Text, request.Context,
BuildAiInstruction(request.Action, request.Role, request.Language, request.Context), ct));
}
return Ok(new AiAssistResult(text, result.Trim()));
catch (CvAiSuggestionException error) { return StatusCode(error.Status, new { code = error.Code }); }
catch (DbUpdateConcurrencyException) { return RevisionConflict(); }
catch (AiGenerationException) { return StatusCode(502, new { code = "cv_ai_unavailable" }); }
}
[HttpPost("variants/{id:int}/ai/apply")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<VariantDto>> ApplyAi(int id, [FromBody] AiApplyRequest request, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (_suggestions is null) return StatusCode(503, new { code = "cv_ai_unavailable" });
if (string.IsNullOrWhiteSpace(request.ProposalToken)) return BadRequest(new { code = "cv_ai_proposal_invalid" });
try { return Ok(ToDto(await _suggestions.ApplyAsync(user.Id, id, request.ProposalToken, request.Result, ct))); }
catch (CvAiSuggestionException error) { return StatusCode(error.Status, new { code = error.Code }); }
catch (DbUpdateConcurrencyException) { return RevisionConflict(); }
catch (AiGenerationException) { return StatusCode(502, new { code = "cv_ai_unavailable" }); }
}
private static string BuildAiInstruction(string? action, string? role, string? language, string? context)
+1
View File
@@ -75,6 +75,7 @@ builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddSingleton<ICvProfileDiffService, CvProfileDiffService>();
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
builder.Services.AddScoped<CvAiSuggestionService>();
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
builder.Services.AddScoped<IApplicationChecklistService, ApplicationChecklistService>();
+99
View File
@@ -0,0 +1,99 @@
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace JobTrackerApi.Services;
// Validates the semantic review's evidence/coverage contract, not the model's reasoning itself.
// Approval never makes a model judgment universal factual proof.
public static class CvAiGrounding
{
public static bool IsSupported(string candidateSource, string proposal, string? assessmentJson)
{
if (string.IsNullOrWhiteSpace(candidateSource) || string.IsNullOrWhiteSpace(proposal) || string.IsNullOrWhiteSpace(assessmentJson)) return false;
try
{
var candidateParts = Parts(candidateSource);
var proposalParts = Parts(proposal);
if (candidateParts.Count == 0 || proposalParts.Count == 0) return false;
// A summary may consolidate known facts, but an employment entry cannot
// borrow another entry's employer, responsibility or numeric result.
IEnumerable<Part> Evidence(Part part) => part.Scope?.StartsWith("entry:", StringComparison.Ordinal) == true
? candidateParts.Where(source => source.Scope == part.Scope) : candidateParts;
foreach (var part in proposalParts)
{
var sourceNumbers = Numbers(Evidence(part)).ToHashSet(StringComparer.Ordinal);
if (Numbers([part]).Any(number => !sourceNumbers.Contains(number))) return false;
}
using var document = JsonDocument.Parse(assessmentJson);
var assessment = document.RootElement;
if (!Keys(assessment, "verdict", "coverageComplete", "claims") || !Supported(assessment)
|| assessment.GetProperty("coverageComplete").ValueKind != JsonValueKind.True) return false;
var claims = assessment.GetProperty("claims");
if (claims.ValueKind != JsonValueKind.Array || claims.GetArrayLength() != proposalParts.Count) return false;
var remaining = proposalParts.OrderByDescending(part => part.Scope?.StartsWith("entry:", StringComparison.Ordinal) == true).ToList();
foreach (var claim in claims.EnumerateArray())
{
if (!Keys(claim, "text", "verdict", "sourceQuotes") || !Supported(claim)
|| claim.GetProperty("text").ValueKind != JsonValueKind.String) return false;
var quotes = claim.GetProperty("sourceQuotes");
if (quotes.ValueKind != JsonValueKind.Array || quotes.GetArrayLength() == 0) return false;
foreach (var quote in quotes.EnumerateArray())
{
if (quote.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(quote.GetString())) return false;
}
var index = remaining.FindIndex(part => part.Text == claim.GetProperty("text").GetString()
&& quotes.EnumerateArray().All(quote => Evidence(part).Any(source => source.Text.Contains(quote.GetString()!, StringComparison.Ordinal))));
if (index < 0) return false;
remaining.RemoveAt(index);
}
return remaining.Count == 0;
}
catch (Exception exception) when (exception is JsonException or InvalidOperationException or FormatException or OverflowException) { return false; }
}
private static bool Supported(JsonElement value) => value.GetProperty("verdict").ValueKind == JsonValueKind.String && value.GetProperty("verdict").GetString() == "supported";
private static bool Keys(JsonElement value, params string[] names) => value.ValueKind == JsonValueKind.Object
&& value.EnumerateObject().Count() == names.Length && names.All(name => value.TryGetProperty(name, out _));
// Whole-document reviews use the existing builder block shape. IDs are routing data, not facts.
private sealed record Part(string? Scope, string Text);
private static List<Part> Parts(string value)
{
if (!value.TrimStart().StartsWith('[')) return value.Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).Select(line => new Part(null, line)).ToList();
using var document = JsonDocument.Parse(value);
if (document.RootElement.ValueKind != JsonValueKind.Array) throw new JsonException();
var result = new List<Part>();
foreach (var block in document.RootElement.EnumerateArray())
{
if (block.ValueKind != JsonValueKind.Object) throw new JsonException();
if (!block.TryGetProperty("id", out var scope) || scope.ValueKind != JsonValueKind.String
|| string.IsNullOrEmpty(scope.GetString())) throw new JsonException();
foreach (var property in block.EnumerateObject())
{
if (property.Name == "id") { if (property.Value.ValueKind != JsonValueKind.String) throw new JsonException(); continue; }
if (property.Name is "title" or "subtitle") Add(property.Value, scope.GetString());
else if (property.Name == "items" && property.Value.ValueKind == JsonValueKind.Array)
foreach (var item in property.Value.EnumerateArray()) Add(item, scope.GetString());
else throw new JsonException();
}
}
return result;
void Add(JsonElement text, string? scope)
{
if (text.ValueKind == JsonValueKind.Null) return;
if (text.ValueKind != JsonValueKind.String) throw new JsonException();
if (!string.IsNullOrWhiteSpace(text.GetString())) result.Add(new(scope, text.GetString()!));
}
}
private static IEnumerable<string> Numbers(IEnumerable<Part> values) => values.SelectMany(value => Regex.Matches(value.Text,
@"(?<![\p{L}\p{N}])[0-9]+(?:[.,][0-9]+)?(?:\s*(?:%|percent\b|prosent\b))?", RegexOptions.IgnoreCase).Cast<Match>())
.Select(match =>
{
var quantity = Regex.Match(match.Value, @"^[0-9]+(?:[.,][0-9]+)?").Value;
var number = decimal.Parse(quantity.Replace(',', '.'), CultureInfo.InvariantCulture).ToString("G29", CultureInfo.InvariantCulture);
return number + (match.Value.Length > quantity.Length ? "%" : string.Empty);
});
}
@@ -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));
}
@@ -30,6 +30,15 @@ public sealed class MeteredSummarizerService(
=> MeterAsync("synchronous.generate", instruction + "\n\n" + text, maxLength,
ct => inner.GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength, ct), cancellationToken);
public Task<AiGenerationResult?> EvaluateCvRewriteAsync(string candidateSource, string proposal, string? contextOnly, CancellationToken cancellationToken = default)
=> MeterAsync("synchronous.cv-factual-review", candidateSource + "\n" + proposal + "\n" + contextOnly, 4096,
ct => inner.EvaluateCvRewriteAsync(candidateSource, proposal, contextOnly, ct), cancellationToken);
public Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(string instruction, string text,
AiGenerationOutputMode outputMode, int maxLength = 180, int minLength = 40, CancellationToken cancellationToken = default)
=> MeterAsync("synchronous.generate", instruction + "\n\n" + text, maxLength,
ct => inner.GenerateSectionWithMetadataAsync(instruction, text, outputMode, maxLength, minLength, ct), cancellationToken);
public Task<AiTextExtractionResult?> ExtractTextAsync(
Stream stream,
string fileName,
+61 -2
View File
@@ -59,6 +59,8 @@ namespace JobTrackerApi.Services
string? FileName
);
public enum AiGenerationOutputMode { Text, CvBlocksJson }
public sealed record AiGenerationResult(
string Text,
string? Provider = null,
@@ -94,6 +96,16 @@ namespace JobTrackerApi.Services
{
new Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40);
// Older providers cannot approve factual changes without implementing this review boundary.
Task<AiGenerationResult?> EvaluateCvRewriteAsync(string candidateSource, string proposal, string? contextOnly, CancellationToken cancellationToken = default)
=> Task.FromResult<AiGenerationResult?>(null);
Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(string instruction, string text,
AiGenerationOutputMode outputMode, int maxLength = 180, int minLength = 40, CancellationToken cancellationToken = default)
=> outputMode == AiGenerationOutputMode.Text
? GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength, cancellationToken)
: Task.FromResult<AiGenerationResult?>(null);
async Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(
string instruction,
string text,
@@ -213,6 +225,51 @@ namespace JobTrackerApi.Services
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength, cancellationToken);
}
public Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(string instruction, string text,
AiGenerationOutputMode outputMode, int maxLength = 180, int minLength = 40, CancellationToken cancellationToken = default)
{
if (outputMode == AiGenerationOutputMode.Text)
return GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength, cancellationToken);
if (outputMode != AiGenerationOutputMode.CvBlocksJson || string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text))
return Task.FromResult<AiGenerationResult?>(null);
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength, cancellationToken, outputMode);
}
public async Task<AiGenerationResult?> EvaluateCvRewriteAsync(string candidateSource, string proposal, string? contextOnly, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(candidateSource) || string.IsNullOrWhiteSpace(proposal)
|| (contextOnly?.Length ?? 0) > 6000
|| (long)candidateSource.Length + proposal.Length + (contextOnly?.Length ?? 0) > AiSummarizeMaxInputChars)
return null; // Evaluation never truncates evidence or the proposal.
Interlocked.Increment(ref _requests);
Interlocked.Increment(ref _cacheMisses);
var watch = Stopwatch.StartNew();
try
{
using var content = new StringContent(JsonSerializer.Serialize(new { candidate_source = candidateSource, proposal, context_only = contextOnly ?? string.Empty }), Encoding.UTF8, "application/json");
using var response = await _httpFactory.CreateClient("ai-service").PostAsync("/cv/evaluate-rewrite", content, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new AiGenerationException("provider_unavailable", "CV factual review is unavailable.", (int)response.StatusCode is 408 or 429 or >= 500);
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (!document.RootElement.TryGetProperty("assessment", out var assessment) || assessment.ValueKind != JsonValueKind.Object)
throw new AiGenerationException("invalid_response", "CV factual review returned an invalid assessment.", true);
lock (_metricsLock) { _lastSuccessAt = DateTimeOffset.UtcNow; _lastError = null; }
return new AiGenerationResult(assessment.GetRawText(), ReadBoundedHeader(response, "X-Ai-Provider"),
ReadBoundedHeader(response, "X-Ai-Model"), ReadBoundedHeader(response, "X-Ai-Fallback-Reason"), ReadBoundedHeader(response, "X-Ai-Route-Reason"));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception exception)
{
Interlocked.Increment(ref _failures);
lock (_metricsLock) { _lastFailureAt = DateTimeOffset.UtcNow; _lastError = "CV factual review failed."; }
if (exception is AiGenerationException) throw;
throw new AiGenerationException("provider_unavailable", "CV factual review could not reach the provider boundary.", true);
}
finally { Interlocked.Add(ref _totalLatencyTicks, watch.ElapsedTicks); }
}
private static string ComposeBoundedPrompt(string instruction, string text)
{
var prefix = $"{instruction}\n\n";
@@ -235,7 +292,8 @@ namespace JobTrackerApi.Services
string text,
int maxLength,
int minLength,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
AiGenerationOutputMode outputMode = AiGenerationOutputMode.Text)
{
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
var normalizedMinLength = Math.Clamp(minLength, AiServiceMinMinLength, AiServiceMaxMinLength);
@@ -245,7 +303,7 @@ namespace JobTrackerApi.Services
}
var composed = ComposeBoundedPrompt(instruction, text);
var key = BuildCacheKey($"rewrite::{composed}", normalizedMaxLength, normalizedMinLength);
var key = BuildCacheKey($"rewrite::{outputMode}::{composed}", normalizedMaxLength, normalizedMinLength);
Interlocked.Increment(ref _requests);
if (_cache.TryGetValue<AiGenerationResult>(key, out var cached))
@@ -268,6 +326,7 @@ namespace JobTrackerApi.Services
text,
max_length = normalizedMaxLength,
min_length = normalizedMinLength,
output_mode = outputMode == AiGenerationOutputMode.CvBlocksJson ? "cv_blocks_json" : "text",
});
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
var sw = Stopwatch.StartNew();