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:
@@ -44,6 +44,7 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
|
||||
public DbSet<CvVariant> CvVariants => Set<CvVariant>();
|
||||
public DbSet<CvVariantVersion> CvVariantVersions => Set<CvVariantVersion>();
|
||||
public DbSet<AiInteraction> AiInteractions => Set<AiInteraction>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -317,6 +318,19 @@ namespace JobTrackerApi.Data
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CvVariantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Phase 5: append-only AI interaction history per job application. Same deny-on-null tenant
|
||||
// filter; indexed for the per-job history read; cascades with the application.
|
||||
// docs/architecture/ai-career-assistant.md.
|
||||
modelBuilder.Entity<AiInteraction>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
modelBuilder.Entity<AiInteraction>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Module, x.CreatedAtUtc });
|
||||
modelBuilder.Entity<AiInteraction>()
|
||||
.HasOne(x => x.JobApplication)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
// Common config for CareerProfile's relational children. The 1:many FK + cascade delete is
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class AiWorkspaceTests
|
||||
{
|
||||
private sealed class FakeAi : ISummarizerService
|
||||
{
|
||||
public string? Next = "## Result\nGenerated suggestion.";
|
||||
public int Calls;
|
||||
public string? LastInstruction;
|
||||
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
|
||||
{
|
||||
Calls++;
|
||||
LastInstruction = instruction;
|
||||
return Task.FromResult(Next);
|
||||
}
|
||||
public Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next);
|
||||
public Task<AiTextExtractionResult?> ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => throw new NotImplementedException();
|
||||
public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task<AiServiceMetrics> GetMetricsAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static (JobTrackerContext db, AiWorkspaceService svc, FakeAi ai) New(string userId)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(s => s.UserId).Returns(userId);
|
||||
var db = new JobTrackerContext(options, currentUser.Object);
|
||||
var ai = new FakeAi();
|
||||
return (db, new AiWorkspaceService(db, ai), ai);
|
||||
}
|
||||
|
||||
private static async Task<int> SeedJobAsync(JobTrackerContext db, string owner)
|
||||
{
|
||||
var company = new Company { OwnerUserId = owner, Name = "Acme" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
var job = new JobApplication { OwnerUserId = owner, CompanyId = company.Id, JobTitle = "Senior Engineer", Status = "Applied", Description = "Build things with C#." };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job.Id;
|
||||
}
|
||||
|
||||
private static AiGenerateRequest Req(string module, string? mode = null) => new(module, mode, null);
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_stores_an_interaction_and_returns_it()
|
||||
{
|
||||
var (db, svc, _) = New("user-1");
|
||||
await using var _ = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
|
||||
var res = await svc.GenerateAsync("user-1", jobId, "My CV text", "Ada", Req("job-analysis"), "gemini", default);
|
||||
|
||||
Assert.NotNull(res);
|
||||
Assert.Equal("job-analysis", res!.Module);
|
||||
Assert.Equal("gemini", res.Provider);
|
||||
Assert.Contains("Generated suggestion", res.ResultJson);
|
||||
Assert.Single(await db.AiInteractions.IgnoreQueryFilters().Where(x => x.JobApplicationId == jobId).ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cover_letter_normalizes_an_unknown_mode_and_labels_the_title()
|
||||
{
|
||||
var (db, svc, _) = New("user-1");
|
||||
await using var _ = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
|
||||
var res = await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("cover-letter", "banana"), "p", default);
|
||||
|
||||
Assert.Equal("professional", res!.Mode);
|
||||
Assert.Contains("Professional", res.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task History_is_newest_first_and_filters_by_module()
|
||||
{
|
||||
var (db, svc, _) = New("user-1");
|
||||
await using var _ = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("job-analysis"), "p", default);
|
||||
await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("career-match"), "p", default);
|
||||
await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("career-match"), "p", default);
|
||||
|
||||
var all = await svc.HistoryAsync("user-1", jobId, null, default);
|
||||
Assert.Equal(3, all.Count);
|
||||
var match = await svc.HistoryAsync("user-1", jobId, "career-match", default);
|
||||
Assert.Equal(2, match.Count);
|
||||
Assert.All(match, m => Assert.Equal("career-match", m.Module));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_removes_only_the_owner_row()
|
||||
{
|
||||
var (db, svc, _) = New("user-1");
|
||||
await using var _ = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
var res = await svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("interview"), "p", default);
|
||||
|
||||
Assert.True(await svc.DeleteAsync("user-1", res!.Id, default));
|
||||
Assert.Null(await svc.GetAsync("user-1", res.Id, default));
|
||||
Assert.False(await svc.DeleteAsync("user-1", res.Id, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_module_is_rejected()
|
||||
{
|
||||
var (db, svc, _) = New("user-1");
|
||||
await using var _ = db;
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("write-my-life-story"), "p", default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_ai_output_raises_unavailable()
|
||||
{
|
||||
var (db, svc, ai) = New("user-1");
|
||||
await using var _ = db;
|
||||
ai.Next = " ";
|
||||
var jobId = await SeedJobAsync(db, "user-1");
|
||||
await Assert.ThrowsAsync<AiUnavailableException>(() => svc.GenerateAsync("user-1", jobId, "cv", "Ada", Req("cover-letter"), "p", default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Another_users_job_is_not_found()
|
||||
{
|
||||
var (db, svc, _) = New("user-1");
|
||||
await using var _ = db;
|
||||
var otherJob = await SeedJobAsync(db, "user-2");
|
||||
|
||||
var res = await svc.GenerateAsync("user-1", otherJob, "cv", "Ada", Req("job-analysis"), "p", default);
|
||||
Assert.Null(res);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobTrackerApi.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAiInteractions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AiInteractions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
JobApplicationId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Module = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Mode = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Title = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Provider = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ResultJson = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AiInteractions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AiInteractions_JobApplications_JobApplicationId",
|
||||
column: x => x.JobApplicationId,
|
||||
principalTable: "JobApplications",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AiInteractions_JobApplicationId",
|
||||
table: "AiInteractions",
|
||||
column: "JobApplicationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AiInteractions_OwnerUserId_JobApplicationId_Module_CreatedAtUtc",
|
||||
table: "AiInteractions",
|
||||
columns: new[] { "OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AiInteractions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,50 @@ namespace JobTrackerApi.Migrations
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.14");
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("JobApplicationId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Module")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OwnerUserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JobApplicationId");
|
||||
|
||||
b.HasIndex("OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc");
|
||||
|
||||
b.ToTable("AiInteractions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.AiWorkspaceNote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1766,6 +1810,17 @@ namespace JobTrackerApi.Migrations
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b =>
|
||||
{
|
||||
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
|
||||
.WithMany()
|
||||
.HasForeignKey("JobApplicationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("JobApplication");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.AiWorkspaceNote", b =>
|
||||
{
|
||||
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
|
||||
|
||||
@@ -40,6 +40,7 @@ builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
|
||||
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
||||
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
|
||||
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
|
||||
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
|
||||
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record AiGenerateRequest(string Module, string? Mode, string? ExtraContext);
|
||||
|
||||
// Thrown when the AI service returns nothing usable — the controller maps it to 502 with the reason.
|
||||
public sealed class AiUnavailableException : Exception
|
||||
{
|
||||
public AiUnavailableException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public interface IAiWorkspaceService
|
||||
{
|
||||
// Runs one module, stores the result as an append-only AiInteraction, and returns it. Never
|
||||
// mutates the profile, a CV variant, or the application — suggestion only.
|
||||
Task<AiInteraction?> GenerateAsync(string ownerUserId, int jobApplicationId, string profileText, string candidateName, AiGenerateRequest req, string provider, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiInteraction>> HistoryAsync(string ownerUserId, int jobApplicationId, string? module, CancellationToken ct);
|
||||
Task<AiInteraction?> GetAsync(string ownerUserId, int id, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct);
|
||||
|
||||
// The module keys this service supports (for the controller/UI to enumerate).
|
||||
IReadOnlyList<string> Modules { get; }
|
||||
}
|
||||
|
||||
public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public IReadOnlyList<string> Modules { get; } = new[]
|
||||
{
|
||||
"job-analysis", "career-match", "cover-letter", "interview", "application-review",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> CoverLetterModes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"professional", "friendly", "short", "detailed", "modern", "traditional",
|
||||
};
|
||||
|
||||
private const string Guardrail =
|
||||
"Preserve every factual claim — never invent employers, titles, dates, qualifications, or metrics. "
|
||||
+ "This is a suggestion the user will review and edit; return only the requested content, in clean markdown, with no preamble.";
|
||||
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly ISummarizerService _ai;
|
||||
|
||||
public AiWorkspaceService(JobTrackerContext db, ISummarizerService ai)
|
||||
{
|
||||
_db = db;
|
||||
_ai = ai;
|
||||
}
|
||||
|
||||
public async Task<AiInteraction?> GenerateAsync(string ownerUserId, int jobApplicationId, string profileText, string candidateName, AiGenerateRequest req, string provider, CancellationToken ct)
|
||||
{
|
||||
var module = (req.Module ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (!Modules.Contains(module)) throw new ArgumentException($"Unknown AI module '{module}'.");
|
||||
|
||||
var job = await _db.JobApplications.Include(j => j.Company)
|
||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||||
if (job is null) return null;
|
||||
|
||||
var jobText = BuildJobContext(job);
|
||||
var profile = string.IsNullOrWhiteSpace(profileText) ? "(no master profile on file yet)" : profileText.Trim();
|
||||
var mode = NormalizeMode(module, req.Mode);
|
||||
var extra = string.IsNullOrWhiteSpace(req.ExtraContext) ? string.Empty : $"\n\nAdditional user context:\n{req.ExtraContext.Trim()}";
|
||||
|
||||
var (instruction, source, title, max) = module switch
|
||||
{
|
||||
"job-analysis" => (JobAnalysisPrompt(), jobText + extra, "Job analysis", 1000),
|
||||
"career-match" => (CareerMatchPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Career match", 1000),
|
||||
"cover-letter" => (CoverLetterPrompt(mode!, candidateName), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", $"Cover letter · {Capitalize(mode!)}", 900),
|
||||
"interview" => (InterviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Interview prep", 1100),
|
||||
"application-review" => (ApplicationReviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Application review", 900),
|
||||
_ => throw new ArgumentException($"Unknown AI module '{module}'."),
|
||||
};
|
||||
|
||||
var result = await _ai.SummarizeSectionAsync($"{instruction} {Guardrail}", source, max, 120);
|
||||
if (string.IsNullOrWhiteSpace(result))
|
||||
{
|
||||
throw new AiUnavailableException("The AI service could not generate this right now. Please try again in a moment.");
|
||||
}
|
||||
|
||||
var interaction = new AiInteraction
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
JobApplicationId = jobApplicationId,
|
||||
Module = module,
|
||||
Mode = mode,
|
||||
Title = title,
|
||||
Provider = string.IsNullOrWhiteSpace(provider) ? "ai-service" : provider,
|
||||
ResultJson = JsonSerializer.Serialize(new { text = result.Trim() }, Json),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.AiInteractions.Add(interaction);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return interaction;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AiInteraction>> HistoryAsync(string ownerUserId, int jobApplicationId, string? module, CancellationToken ct)
|
||||
{
|
||||
var q = _db.AiInteractions.Where(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == jobApplicationId);
|
||||
if (!string.IsNullOrWhiteSpace(module)) { var m = module.Trim().ToLowerInvariant(); q = q.Where(x => x.Module == m); }
|
||||
return await q.OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public Task<AiInteraction?> GetAsync(string ownerUserId, int id, CancellationToken ct) =>
|
||||
_db.AiInteractions.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct);
|
||||
|
||||
public async Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct)
|
||||
{
|
||||
var row = await GetAsync(ownerUserId, id, ct);
|
||||
if (row is null) return false;
|
||||
_db.AiInteractions.Remove(row);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? NormalizeMode(string module, string? mode)
|
||||
{
|
||||
if (module != "cover-letter") return null;
|
||||
var m = (mode ?? "professional").Trim().ToLowerInvariant();
|
||||
return CoverLetterModes.Contains(m) ? m : "professional";
|
||||
}
|
||||
|
||||
private static string BuildJobContext(JobApplication job)
|
||||
{
|
||||
var parts = new[]
|
||||
{
|
||||
Field("Role", job.JobTitle),
|
||||
Field("Company", job.Company?.Name),
|
||||
Field("Status", job.Status),
|
||||
Field("Summary", job.ShortSummary),
|
||||
Field("Description", job.Description),
|
||||
Field("Translated description", job.TranslatedDescription),
|
||||
Field("Notes", job.Notes),
|
||||
Field("URL", job.JobUrl),
|
||||
};
|
||||
return string.Join("\n", parts.Where(p => p != null));
|
||||
}
|
||||
|
||||
private static string? Field(string label, string? value) => string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value.Trim()}";
|
||||
private static string Capitalize(string s) => s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..];
|
||||
|
||||
// --- Prompts. Each asks for markdown with clear sections; the guardrail is appended by the caller. ---
|
||||
|
||||
private static string JobAnalysisPrompt() =>
|
||||
"Analyse this job advert. Return markdown with these sections: **Company**, **Role**, **Required skills**, "
|
||||
+ "**Nice-to-have skills**, **Technologies**, **Experience**, **Education**, **Soft skills**, **Responsibilities**, "
|
||||
+ "**Salary** (only if stated), **Benefits**, **Work model**, **Visa requirements**, **Language requirements**, "
|
||||
+ "**Summary** (2–3 sentences), **Likely interview topics**, and **Confidence** (High/Medium/Low with one line on why). "
|
||||
+ "Omit any field the advert does not mention rather than guessing.";
|
||||
|
||||
private static string CareerMatchPrompt() =>
|
||||
"Compare the candidate profile against the job advert. Return markdown with: **Match** (a single percentage with one "
|
||||
+ "line of reasoning), **Strengths**, **Weaknesses**, **Missing skills**, **Most relevant experience**, and "
|
||||
+ "**Suggested improvements** (concrete, actionable). Base every point only on what the profile actually shows.";
|
||||
|
||||
private static string CoverLetterPrompt(string mode, string candidateName) =>
|
||||
$"Write a cover letter for {(string.IsNullOrWhiteSpace(candidateName) ? "the candidate" : candidateName)} for this role in a "
|
||||
+ $"{ModeGuidance(mode)} Ground every claim in the candidate profile; do not invent experience. Return only the letter body.";
|
||||
|
||||
private static string ModeGuidance(string mode) => mode switch
|
||||
{
|
||||
"friendly" => "warm, personable style — approachable but still professional.",
|
||||
"short" => "concise style — 3 short paragraphs at most, every sentence earning its place.",
|
||||
"detailed" => "thorough style — cover motivation, the strongest matching experience, and fit, without padding.",
|
||||
"modern" => "modern, direct style — confident, plain language, no clichés.",
|
||||
"traditional" => "traditional, formal style — conventional structure and measured tone.",
|
||||
_ => "professional, confident style.",
|
||||
};
|
||||
|
||||
private static string InterviewPrompt() =>
|
||||
"Create an interview preparation brief in markdown with: **Company research summary** (from the advert only), "
|
||||
+ "**Likely interview questions**, **Behavioural questions**, **Technical questions**, **Suggested STAR answers** "
|
||||
+ "(outline Situation/Task/Action/Result using the candidate's real experience), and a **Preparation checklist**.";
|
||||
|
||||
private static string ApplicationReviewPrompt() =>
|
||||
"Review this application (candidate profile as the material to be submitted, against the job advert). Return markdown "
|
||||
+ "with: **Overall strength** (a one-line verdict + rating out of 10), **Missing information**, **Weak areas**, "
|
||||
+ "**ATS issues** (keywords/formatting that could hurt automated screening), **Grammar & clarity**, and "
|
||||
+ "**Formatting suggestions**. Be specific and constructive.";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
// Phase 5 — AI Career Assistant. Append-only history of every AI interaction for a job application.
|
||||
// Unlike AiWorkspaceNote (one row per (owner, job, type), overwritten on regenerate — a cache), this
|
||||
// keeps EVERY generation so the user can restore, compare, reuse, or delete past outputs. Suggestion
|
||||
// only: an interaction never mutates the master profile, a CV variant, or the application itself.
|
||||
// docs/architecture/ai-career-assistant.md.
|
||||
public sealed class AiInteraction
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public int JobApplicationId { get; set; }
|
||||
public JobApplication? JobApplication { get; set; }
|
||||
|
||||
// job-analysis | career-match | cover-letter | interview | application-review
|
||||
public string Module { get; set; } = string.Empty;
|
||||
|
||||
// Optional mode within a module (e.g. cover-letter: professional|friendly|short|detailed|modern|traditional).
|
||||
public string? Mode { get; set; }
|
||||
|
||||
// Human label for the history list, e.g. "Cover letter · Professional".
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
// Resolved provider label at generation time (transparency for the history list).
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
// The generated suggestion. { text: string, meta?: object } — text is markdown the UI renders;
|
||||
// meta carries any structured extras (e.g. career-match percent).
|
||||
public string ResultJson { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
Reference in New Issue
Block a user