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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user