fix(cv): gate AI proposals on grounding and source freshness
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user