feat(ai): AI Workspace per job application — modules + append-only history
Phase 5 backend. A unified AI Workspace for each application, orchestrating the
five suggestion modules through the existing ISummarizerService provider
abstraction and storing every generation as append-only history (AiInteraction)
so outputs can be reused, compared, and deleted — distinct from the existing
AiWorkspaceNote cache (one row, overwritten).
Modules (all suggestion-only, "never invent facts" guardrail, never mutate the
profile/variant/application): job-analysis, career-match, cover-letter (6 modes),
interview, application-review. Each builds a prompt from the job + master profile
text and returns markdown.
- Models/AiInteraction.cs + migration AddAiInteractions (verified on container)
- Services/AiWorkspaceService.cs (prompts, history, delete)
- Controllers/AiWorkspaceController.cs (/api/jobapplications/{id}/ai:
generate, history, delete, modules+provider)
- 7 tests (store, history filter/order, delete, mode normalization, unknown
module, empty output, tenant scoping); 306 backend green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
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;
|
||||
|
||||
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config)
|
||||
{
|
||||
_users = users;
|
||||
_workspace = workspace;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
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, DateTimeOffset CreatedAtUtc);
|
||||
|
||||
[HttpGet("modules")]
|
||||
public ActionResult<object> Modules() => Ok(new { modules = _workspace.Modules, provider = ResolveProvider() });
|
||||
|
||||
[HttpPost("generate")]
|
||||
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.");
|
||||
|
||||
try
|
||||
{
|
||||
var interaction = await _workspace.GenerateAsync(
|
||||
user.Id, jobId, user.ProfileCvText ?? string.Empty, ResolveName(user),
|
||||
new AiGenerateRequest(request.Module, request.Mode, request.ExtraContext), ResolveProvider(), ct);
|
||||
return interaction is null ? NotFound() : Ok(ToDto(interaction));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
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.CreatedAtUtc);
|
||||
}
|
||||
Reference in New Issue
Block a user