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)