feat: persist candidate fit and focus plan, stop re-running on every open
Extends the interview-prep persistence pattern (previous commit) to the other two AI-generated per-job outputs that were re-running their full AI call chain on every tab open: candidate-fit (4 AI calls) and focus-plan (4 AI calls). Across all three tabs that's 9 AI calls fired every single time a user revisits a job's AI workspace tabs. Generalized into AiWorkspaceNote (OwnerUserId, JobApplicationId, NoteType, ResultJson) rather than duplicating InterviewPrepNote's per-field-column shape: CandidateFitDto and FocusPlanDto are irregular and nested (up to 13 fields including a nested guidance object), where per-field columns would be unreasonable. One table, keyed by note type, serving both. Same rules as interview prep: reuse across calls, regenerate when the attachment selection changes, regenerate on explicit refresh. Frontend gets the same "Regenerate" button on both tabs. 3 new tests (persist+reuse for both, refresh for candidate-fit). Verified against the real dev DB.
This commit is contained in:
@@ -31,6 +31,7 @@ namespace JobTrackerApi.Data
|
|||||||
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
|
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
|
||||||
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
|
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
|
||||||
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
|
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
|
||||||
|
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -179,6 +180,22 @@ namespace JobTrackerApi.Data
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.JobApplicationId)
|
.HasForeignKey(x => x.JobApplicationId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// Generic AI workspace note persistence (candidate fit, focus plan) -- same rationale as
|
||||||
|
// InterviewPrepNote above, generalized because these DTOs are irregular/nested enough that
|
||||||
|
// per-field columns would be unreasonable.
|
||||||
|
modelBuilder.Entity<AiWorkspaceNote>()
|
||||||
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<AiWorkspaceNote>()
|
||||||
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.NoteType })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<AiWorkspaceNote>()
|
||||||
|
.HasOne(x => x.JobApplication)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.JobApplicationId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using JobTrackerApi.Controllers;
|
||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using JobTrackerApi.Tests.TestSupport;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
// Candidate fit and focus plan share AiWorkspaceNote persistence with the same rules as
|
||||||
|
// InterviewPrepNote: reuse across calls, regenerate on refresh, regenerate when the attachment
|
||||||
|
// selection changes (career-workspace-implementation-roadmap.md Phase F5).
|
||||||
|
public sealed class AiWorkspaceNotePersistenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCandidateFit_persists_and_reuses_the_generated_note()
|
||||||
|
{
|
||||||
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||||
|
var job = await SeedJobWithCvAsync(db);
|
||||||
|
|
||||||
|
var summarizer = new Mock<ISummarizerService>();
|
||||||
|
var callCount = 0;
|
||||||
|
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||||
|
.ReturnsAsync(() => { callCount++; return $"Summary call {callCount}"; });
|
||||||
|
|
||||||
|
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||||
|
|
||||||
|
var first = await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None);
|
||||||
|
var callsAfterFirst = callCount;
|
||||||
|
|
||||||
|
var second = await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(GetDto(first).MatchSummary, GetDto(second).MatchSummary);
|
||||||
|
Assert.Equal(callsAfterFirst, callCount);
|
||||||
|
|
||||||
|
var stored = Assert.Single(db.AiWorkspaceNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id && x.NoteType == "candidate-fit"));
|
||||||
|
Assert.NotEmpty(stored.ResultJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCandidateFit_regenerates_when_refresh_is_requested()
|
||||||
|
{
|
||||||
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||||
|
var job = await SeedJobWithCvAsync(db);
|
||||||
|
|
||||||
|
var summarizer = new Mock<ISummarizerService>();
|
||||||
|
var callCount = 0;
|
||||||
|
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||||
|
.ReturnsAsync(() => { callCount++; return $"Summary call {callCount}"; });
|
||||||
|
|
||||||
|
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||||
|
|
||||||
|
await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None);
|
||||||
|
var callsAfterFirst = callCount;
|
||||||
|
|
||||||
|
var refreshed = await controller.GetCandidateFit(job.Id, null, true, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(callCount > callsAfterFirst);
|
||||||
|
Assert.NotNull(GetDto(refreshed).MatchSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFocusPlan_persists_and_reuses_the_generated_note()
|
||||||
|
{
|
||||||
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||||
|
var job = await SeedJobWithCvAsync(db);
|
||||||
|
|
||||||
|
var summarizer = new Mock<ISummarizerService>();
|
||||||
|
var callCount = 0;
|
||||||
|
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||||
|
.ReturnsAsync(() => { callCount++; return $"Text {callCount}"; });
|
||||||
|
|
||||||
|
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||||
|
|
||||||
|
await controller.GetFocusPlan(job.Id, null, false, CancellationToken.None);
|
||||||
|
var callsAfterFirst = callCount;
|
||||||
|
|
||||||
|
await controller.GetFocusPlan(job.Id, null, false, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(callsAfterFirst, callCount);
|
||||||
|
|
||||||
|
var stored = Assert.Single(db.AiWorkspaceNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id && x.NoteType == "focus-plan"));
|
||||||
|
Assert.NotEmpty(stored.ResultJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JobApplicationsController.CandidateFitDto GetDto(ActionResult<JobApplicationsController.CandidateFitDto> result)
|
||||||
|
=> (JobApplicationsController.CandidateFitDto)Assert.IsType<OkObjectResult>(result.Result).Value!;
|
||||||
|
|
||||||
|
private static async Task<JobApplication> SeedJobWithCvAsync(JobTrackerContext db)
|
||||||
|
{
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "user-1",
|
||||||
|
UserName = "user@example.test",
|
||||||
|
Email = "user@example.test",
|
||||||
|
ProfileCvText = "Built .NET APIs and led backend delivery with SQL and Docker.",
|
||||||
|
ProfileCvStructureJson = "[]",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication
|
||||||
|
{
|
||||||
|
JobTitle = "Backend Developer",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
Description = "Needs .NET, SQL, and Docker experience.",
|
||||||
|
};
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
|
||||||
|
{
|
||||||
|
var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId);
|
||||||
|
var controller = new JobApplicationsController(
|
||||||
|
db,
|
||||||
|
summarizer,
|
||||||
|
Mock.Of<IAppEmailSender>(),
|
||||||
|
TestHostFactory.CreateUserManager(user).Object,
|
||||||
|
NullLogger<JobApplicationsController>.Instance,
|
||||||
|
Mock.Of<ICvTemplateRenderer>(),
|
||||||
|
Mock.Of<ICvPdfExporter>());
|
||||||
|
controller.ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||||
|
}, "test"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2192,7 +2192,7 @@ Canonical profile:
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}/candidate-fit")]
|
[HttpGet("{id:int}/candidate-fit")]
|
||||||
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var job = await _db.JobApplications
|
var job = await _db.JobApplications
|
||||||
.Include(j => j.Company)
|
.Include(j => j.Company)
|
||||||
@@ -2202,6 +2202,13 @@ Canonical profile:
|
|||||||
var userId = CurrentUserId;
|
var userId = CurrentUserId;
|
||||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||||
|
|
||||||
|
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
|
||||||
|
if (!refresh)
|
||||||
|
{
|
||||||
|
var cached = await TryGetCachedAiNoteAsync<CandidateFitDto>(userId, id, "candidate-fit", attachmentSignature, cancellationToken);
|
||||||
|
if (cached is not null) return Ok(cached);
|
||||||
|
}
|
||||||
|
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||||
var cvText = user?.ProfileCvText;
|
var cvText = user?.ProfileCvText;
|
||||||
if (string.IsNullOrWhiteSpace(cvText))
|
if (string.IsNullOrWhiteSpace(cvText))
|
||||||
@@ -2298,7 +2305,7 @@ Candidate CV/profile:
|
|||||||
"Close with a clear expression of interest and availability."
|
"Close with a clear expression of interest and availability."
|
||||||
});
|
});
|
||||||
|
|
||||||
return Ok(new CandidateFitDto(
|
var dto = new CandidateFitDto(
|
||||||
MatchSummary: matchSummary,
|
MatchSummary: matchSummary,
|
||||||
FitLevel: fitLevel,
|
FitLevel: fitLevel,
|
||||||
MatchScore: matchScore,
|
MatchScore: matchScore,
|
||||||
@@ -2312,11 +2319,14 @@ Candidate CV/profile:
|
|||||||
TailoredPitch: tailoredPitch,
|
TailoredPitch: tailoredPitch,
|
||||||
Guidance: guidance,
|
Guidance: guidance,
|
||||||
CoverLetterDraft: coverLetterDraft,
|
CoverLetterDraft: coverLetterDraft,
|
||||||
RecruiterMessageDraft: recruiterMessageDraft));
|
RecruiterMessageDraft: recruiterMessageDraft);
|
||||||
|
|
||||||
|
await SaveAiNoteAsync(userId, id, "candidate-fit", attachmentSignature, dto, cancellationToken);
|
||||||
|
return Ok(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}/focus-plan")]
|
[HttpGet("{id:int}/focus-plan")]
|
||||||
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var job = await _db.JobApplications
|
var job = await _db.JobApplications
|
||||||
.Include(j => j.Company)
|
.Include(j => j.Company)
|
||||||
@@ -2326,6 +2336,13 @@ Candidate CV/profile:
|
|||||||
var userId = CurrentUserId;
|
var userId = CurrentUserId;
|
||||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||||
|
|
||||||
|
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
|
||||||
|
if (!refresh)
|
||||||
|
{
|
||||||
|
var cached = await TryGetCachedAiNoteAsync<FocusPlanDto>(userId, id, "focus-plan", attachmentSignature, cancellationToken);
|
||||||
|
if (cached is not null) return Ok(cached);
|
||||||
|
}
|
||||||
|
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||||
var cvText = user?.ProfileCvText;
|
var cvText = user?.ProfileCvText;
|
||||||
if (string.IsNullOrWhiteSpace(cvText))
|
if (string.IsNullOrWhiteSpace(cvText))
|
||||||
@@ -2388,13 +2405,41 @@ Candidate master CV:
|
|||||||
|
|
||||||
var followUpApproach = BuildFollowUpApproach(job.Status, matchedTags, missingTags);
|
var followUpApproach = BuildFollowUpApproach(job.Status, matchedTags, missingTags);
|
||||||
|
|
||||||
return Ok(new FocusPlanDto(
|
var dto = new FocusPlanDto(
|
||||||
ImmediatePriorities: immediatePriorities,
|
ImmediatePriorities: immediatePriorities,
|
||||||
CvBulletIdeas: cvBulletIdeas,
|
CvBulletIdeas: cvBulletIdeas,
|
||||||
ProofPointsToLeadWith: proofPointsToLeadWith,
|
ProofPointsToLeadWith: proofPointsToLeadWith,
|
||||||
CoverLetterAngles: coverLetterAngles,
|
CoverLetterAngles: coverLetterAngles,
|
||||||
FollowUpApproach: followUpApproach,
|
FollowUpApproach: followUpApproach,
|
||||||
StrategicSummary: strategicSummary));
|
StrategicSummary: strategicSummary);
|
||||||
|
|
||||||
|
await SaveAiNoteAsync(userId, id, "focus-plan", attachmentSignature, dto, cancellationToken);
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T?> TryGetCachedAiNoteAsync<T>(string userId, int jobApplicationId, string noteType, string attachmentSignature, CancellationToken cancellationToken) where T : class
|
||||||
|
{
|
||||||
|
var existing = await _db.AiWorkspaceNotes.FirstOrDefaultAsync(
|
||||||
|
x => x.OwnerUserId == userId && x.JobApplicationId == jobApplicationId && x.NoteType == noteType && x.AttachmentContextSignature == attachmentSignature,
|
||||||
|
cancellationToken);
|
||||||
|
if (existing is null) return null;
|
||||||
|
return JsonSerializer.Deserialize<T>(existing.ResultJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAiNoteAsync<T>(string userId, int jobApplicationId, string noteType, string attachmentSignature, T dto, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var note = await _db.AiWorkspaceNotes.FirstOrDefaultAsync(
|
||||||
|
x => x.OwnerUserId == userId && x.JobApplicationId == jobApplicationId && x.NoteType == noteType,
|
||||||
|
cancellationToken);
|
||||||
|
if (note is null)
|
||||||
|
{
|
||||||
|
note = new AiWorkspaceNote { OwnerUserId = userId, JobApplicationId = jobApplicationId, NoteType = noteType };
|
||||||
|
_db.AiWorkspaceNotes.Add(note);
|
||||||
|
}
|
||||||
|
note.AttachmentContextSignature = attachmentSignature;
|
||||||
|
note.ResultJson = JsonSerializer.Serialize(dto);
|
||||||
|
note.GeneratedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}/interview-prep")]
|
[HttpGet("{id:int}/interview-prep")]
|
||||||
|
|||||||
@@ -680,12 +680,34 @@ public static class StartupInitializationExtensions
|
|||||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId" ON "InterviewPrepNotes" ("OwnerUserId", "JobApplicationId");""");
|
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId" ON "InterviewPrepNotes" ("OwnerUserId", "JobApplicationId");""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generic AI workspace note persistence (candidate fit, focus plan) -- same
|
||||||
|
// rationale as EnsureInterviewPrepNotesTable, generalized for DTOs too irregular
|
||||||
|
// for per-field columns.
|
||||||
|
static void EnsureAiWorkspaceNotesTable(DbConnection c)
|
||||||
|
{
|
||||||
|
Exec(c, """
|
||||||
|
CREATE TABLE IF NOT EXISTS "AiWorkspaceNotes" (
|
||||||
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_AiWorkspaceNotes" PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"OwnerUserId" TEXT NOT NULL,
|
||||||
|
"JobApplicationId" INTEGER NOT NULL,
|
||||||
|
"NoteType" TEXT NOT NULL,
|
||||||
|
"AttachmentContextSignature" TEXT NOT NULL,
|
||||||
|
"ResultJson" TEXT NOT NULL,
|
||||||
|
"GeneratedAtUtc" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_AiWorkspaceNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType" ON "AiWorkspaceNotes" ("OwnerUserId", "JobApplicationId", "NoteType");""");
|
||||||
|
}
|
||||||
|
|
||||||
EnsureGmailConnectionsTable(conn);
|
EnsureGmailConnectionsTable(conn);
|
||||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||||
EnsureImapConnectionsTable(conn);
|
EnsureImapConnectionsTable(conn);
|
||||||
EnsureCvTables(conn);
|
EnsureCvTables(conn);
|
||||||
EnsureCareerProfileTables(conn);
|
EnsureCareerProfileTables(conn);
|
||||||
EnsureInterviewPrepNotesTable(conn);
|
EnsureInterviewPrepNotesTable(conn);
|
||||||
|
EnsureAiWorkspaceNotesTable(conn);
|
||||||
|
|
||||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||||
// and at least one of the new columns already exists.
|
// and at least one of the new columns already exists.
|
||||||
@@ -837,6 +859,15 @@ public static class StartupInitializationExtensions
|
|||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiWorkspaceNotes", "Id");
|
||||||
|
|
||||||
|
if (!MySqlIndexExists(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "CREATE UNIQUE INDEX `IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType` ON `AiWorkspaceNotes` (`OwnerUserId`, `JobApplicationId`, `NoteType`);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
|
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
|
||||||
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
||||||
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
|
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
|
||||||
@@ -1116,6 +1147,24 @@ public static class StartupInitializationExtensions
|
|||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generic AI workspace note persistence (candidate fit, focus plan).
|
||||||
|
if (!HasMySqlTable(conn, "AiWorkspaceNotes"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiWorkspaceNotes` (
|
||||||
|
`Id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`OwnerUserId` varchar(255) NOT NULL,
|
||||||
|
`JobApplicationId` int NOT NULL,
|
||||||
|
`NoteType` varchar(50) NOT NULL,
|
||||||
|
`AttachmentContextSignature` longtext NOT NULL,
|
||||||
|
`ResultJson` longtext NOT NULL,
|
||||||
|
`GeneratedAtUtc` datetime(6) NOT NULL,
|
||||||
|
PRIMARY KEY (`Id`),
|
||||||
|
CONSTRAINT `FK_AiWorkspaceNotes_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace JobTrackerApi.Models;
|
||||||
|
|
||||||
|
// Generic persistence for per-job AI workspace outputs whose shape is irregular/nested enough
|
||||||
|
// that per-field columns (as used by InterviewPrepNote) would be unreasonable -- candidate fit
|
||||||
|
// and focus plan each make several AI calls and return DTOs with 6-13 fields including nested
|
||||||
|
// objects. One row per (owner, job, note type); ResultJson is the serialized DTO. See
|
||||||
|
// career-workspace-implementation-roadmap.md Phase F5 -- "persist interview prep / fit outputs".
|
||||||
|
public sealed class AiWorkspaceNote
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string OwnerUserId { get; set; } = string.Empty;
|
||||||
|
public int JobApplicationId { get; set; }
|
||||||
|
public JobApplication? JobApplication { get; set; }
|
||||||
|
// "candidate-fit" | "focus-plan"
|
||||||
|
public string NoteType { get; set; } = string.Empty;
|
||||||
|
public string AttachmentContextSignature { get; set; } = string.Empty;
|
||||||
|
public string ResultJson { get; set; } = string.Empty;
|
||||||
|
public DateTimeOffset GeneratedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -289,6 +289,18 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||||
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
|
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
|
||||||
|
|
||||||
|
// Persisted server-side like interview prep (career-workspace-implementation-roadmap.md Phase
|
||||||
|
// F5); Regenerate is the explicit escape hatch when the job has changed since it was written.
|
||||||
|
const regenerateCandidateFit = useCallback(() => {
|
||||||
|
if (!jobId) return;
|
||||||
|
setLoadingCandidateFit(true);
|
||||||
|
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
|
||||||
|
candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, r.data);
|
||||||
|
setCandidateFit(r.data);
|
||||||
|
toast("Candidate fit regenerated.", "success");
|
||||||
|
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate candidate fit."), "error")).finally(() => setLoadingCandidateFit(false));
|
||||||
|
}, [jobId, selectedAttachmentCsv, candidateFitCache, toast]);
|
||||||
|
|
||||||
// Match score is deterministic and cheap: load it on the Candidate Fit tab
|
// Match score is deterministic and cheap: load it on the Candidate Fit tab
|
||||||
// independently of the slow AI narrative so users see the number instantly.
|
// independently of the slow AI narrative so users see the number instantly.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -348,6 +360,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
||||||
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
|
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
|
||||||
|
|
||||||
|
const regenerateFocusPlan = useCallback(() => {
|
||||||
|
if (!jobId) return;
|
||||||
|
setLoadingFocusPlan(true);
|
||||||
|
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
|
||||||
|
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, r.data);
|
||||||
|
setFocusPlan(r.data);
|
||||||
|
toast("Focus plan regenerated.", "success");
|
||||||
|
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate focus plan."), "error")).finally(() => setLoadingFocusPlan(false));
|
||||||
|
}, [jobId, selectedAttachmentCsv, focusPlanCache, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !jobId || tab !== 7 || interviewPrep) return;
|
if (!open || !jobId || tab !== 7 || interviewPrep) return;
|
||||||
const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`;
|
const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`;
|
||||||
@@ -1143,6 +1165,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
{tab === 5 && (
|
{tab === 5 && (
|
||||||
<Box>
|
<Box>
|
||||||
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
|
||||||
|
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
|
||||||
|
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
|
||||||
@@ -1171,6 +1198,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
|
|
||||||
{tab === 6 && (
|
{tab === 6 && (
|
||||||
<Box>
|
<Box>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||||
|
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
|
||||||
|
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
|
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
|
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
|
||||||
|
|||||||
Reference in New Issue
Block a user