134 lines
6.0 KiB
C#
134 lines
6.0 KiB
C#
using System.Text.Json;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace JobTrackerApi.Controllers;
|
|
|
|
// Phase 5 — the AI Workspace for one job application. Every module runs through ISummarizerService and
|
|
// is stored as append-only history; nothing is applied automatically. docs/architecture/ai-career-assistant.md.
|
|
[ApiController]
|
|
[Route("api/jobapplications/{jobId:int}/ai")]
|
|
[Authorize(AuthenticationSchemes = "local")]
|
|
public sealed class AiWorkspaceController : ControllerBase
|
|
{
|
|
private readonly UserManager<ApplicationUser> _users;
|
|
private readonly IAiWorkspaceService _workspace;
|
|
private readonly IConfiguration _config;
|
|
private readonly JobTrackerApi.Data.JobTrackerContext? _db;
|
|
private readonly AiUsageMeter? _usage;
|
|
private readonly AiUsageExecutionScope? _usageScope;
|
|
|
|
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config, JobTrackerApi.Data.JobTrackerContext? db = null, AiUsageMeter? usage = null, AiUsageExecutionScope? usageScope = null)
|
|
{
|
|
_users = users;
|
|
_workspace = workspace;
|
|
_config = config;
|
|
_db = db;
|
|
_usage = usage;
|
|
_usageScope = usageScope;
|
|
}
|
|
|
|
public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext);
|
|
public sealed record InteractionDto(int Id, string Module, string? Mode, string Title, string Provider, JsonElement Result, int InputCharacterCount, int OutputCharacterCount, int EstimatedTokenCount, DateTimeOffset CreatedAtUtc);
|
|
|
|
[HttpGet("modules")]
|
|
public ActionResult<object> Modules() => Ok(new { modules = _workspace.Modules, provider = ResolveProvider() });
|
|
|
|
[HttpPost("generate")]
|
|
[Authorize(Policy = ProEntitlement.Policy)]
|
|
public async Task<ActionResult<InteractionDto>> Generate(int jobId, [FromBody] GenerateRequest request, CancellationToken ct)
|
|
{
|
|
var user = await _users.GetUserAsync(User);
|
|
if (user is null) return Unauthorized();
|
|
if (string.IsNullOrWhiteSpace(request?.Module)) return BadRequest("Choose an AI module.");
|
|
|
|
AiUsageReservation? reservation = null;
|
|
if (_db is not null)
|
|
{
|
|
var roles = await _users.GetRolesAsync(user);
|
|
var entitlements = AccountPlans.ForRoles(roles);
|
|
var usage = _usage ?? new AiUsageMeter(_db, TimeProvider.System);
|
|
var estimate = AiUsageMeter.ReservationFor($"workspace.{request.Module.Trim().ToLowerInvariant()}");
|
|
try
|
|
{
|
|
reservation = await usage.ReserveAsync(
|
|
user.Id,
|
|
entitlements,
|
|
"workspace",
|
|
Guid.NewGuid().ToString("D"),
|
|
$"workspace.{request.Module.Trim().ToLowerInvariant()}",
|
|
estimate.InputCharacters,
|
|
estimate.EstimatedTokens,
|
|
ct);
|
|
}
|
|
catch (AiUsageLimitException ex)
|
|
{
|
|
return StatusCode(StatusCodes.Status429TooManyRequests, ex.Message);
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
using var metering = _usageScope?.Suppress();
|
|
var interaction = await _workspace.GenerateAsync(
|
|
user.Id, jobId, user.ProfileCvText ?? string.Empty, ResolveName(user),
|
|
new AiGenerateRequest(request.Module, request.Mode, request.ExtraContext), ResolveProvider(), ct);
|
|
if (interaction is null)
|
|
{
|
|
if (reservation is not null) await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).ReleaseAsync(reservation.Record.Id, ct);
|
|
return NotFound();
|
|
}
|
|
if (reservation is not null)
|
|
await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).FinalizeAsync(
|
|
reservation.Record.Id, interaction.InputCharacterCount, interaction.OutputCharacterCount, ct);
|
|
return Ok(ToDto(interaction));
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
if (reservation is not null) await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).ReleaseAsync(reservation.Record.Id, ct);
|
|
return BadRequest(ex.Message);
|
|
}
|
|
catch (AiUnavailableException ex)
|
|
{
|
|
return StatusCode(StatusCodes.Status502BadGateway, ex.Message);
|
|
}
|
|
}
|
|
|
|
[HttpGet("history")]
|
|
public async Task<ActionResult<IEnumerable<InteractionDto>>> History(int jobId, [FromQuery] string? module, CancellationToken ct)
|
|
{
|
|
var user = await _users.GetUserAsync(User);
|
|
if (user is null) return Unauthorized();
|
|
var history = await _workspace.HistoryAsync(user.Id, jobId, module, ct);
|
|
return Ok(history.Select(ToDto));
|
|
}
|
|
|
|
[HttpDelete("history/{id:int}")]
|
|
public async Task<IActionResult> Delete(int jobId, int id, CancellationToken ct)
|
|
{
|
|
var user = await _users.GetUserAsync(User);
|
|
if (user is null) return Unauthorized();
|
|
return await _workspace.DeleteAsync(user.Id, id, ct) ? NoContent() : NotFound();
|
|
}
|
|
|
|
private string ResolveProvider() =>
|
|
_config["Ai:Provider"] ?? Environment.GetEnvironmentVariable("AI_PROVIDER") ?? "ai-service";
|
|
|
|
private static string ResolveName(ApplicationUser user)
|
|
{
|
|
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
|
if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim();
|
|
if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim();
|
|
return name ?? string.Empty;
|
|
}
|
|
|
|
private static InteractionDto ToDto(AiInteraction x) => new(
|
|
x.Id, x.Module, x.Mode, x.Title, x.Provider,
|
|
JsonSerializer.Deserialize<JsonElement>(string.IsNullOrWhiteSpace(x.ResultJson) ? "{}" : x.ResultJson),
|
|
x.InputCharacterCount, x.OutputCharacterCount, x.EstimatedTokenCount, x.CreatedAtUtc);
|
|
}
|