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 _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 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 Modules() => Ok(new { modules = _workspace.Modules, provider = ResolveProvider() }); [HttpPost("generate")] [Authorize(Policy = ProEntitlement.Policy)] public async Task> 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>> 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 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(string.IsNullOrWhiteSpace(x.ResultJson) ? "{}" : x.ResultJson), x.InputCharacterCount, x.OutputCharacterCount, x.EstimatedTokenCount, x.CreatedAtUtc); }