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:
cesnimda
2026-07-12 15:41:58 +02:00
parent 5916f09852
commit 00a035ea20
6 changed files with 312 additions and 6 deletions
@@ -2192,7 +2192,7 @@ Canonical profile:
}
[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
.Include(j => j.Company)
@@ -2202,6 +2202,13 @@ Canonical profile:
var userId = CurrentUserId;
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 cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
@@ -2298,7 +2305,7 @@ Candidate CV/profile:
"Close with a clear expression of interest and availability."
});
return Ok(new CandidateFitDto(
var dto = new CandidateFitDto(
MatchSummary: matchSummary,
FitLevel: fitLevel,
MatchScore: matchScore,
@@ -2312,11 +2319,14 @@ Candidate CV/profile:
TailoredPitch: tailoredPitch,
Guidance: guidance,
CoverLetterDraft: coverLetterDraft,
RecruiterMessageDraft: recruiterMessageDraft));
RecruiterMessageDraft: recruiterMessageDraft);
await SaveAiNoteAsync(userId, id, "candidate-fit", attachmentSignature, dto, cancellationToken);
return Ok(dto);
}
[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
.Include(j => j.Company)
@@ -2326,6 +2336,13 @@ Candidate CV/profile:
var userId = CurrentUserId;
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 cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
@@ -2388,13 +2405,41 @@ Candidate master CV:
var followUpApproach = BuildFollowUpApproach(job.Status, matchedTags, missingTags);
return Ok(new FocusPlanDto(
var dto = new FocusPlanDto(
ImmediatePriorities: immediatePriorities,
CvBulletIdeas: cvBulletIdeas,
ProofPointsToLeadWith: proofPointsToLeadWith,
CoverLetterAngles: coverLetterAngles,
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")]
@@ -680,12 +680,34 @@ public static class StartupInitializationExtensions
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);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
EnsureCvTables(conn);
EnsureCareerProfileTables(conn);
EnsureInterviewPrepNotesTable(conn);
EnsureAiWorkspaceNotesTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
@@ -837,6 +859,15 @@ public static class StartupInitializationExtensions
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/
// Correspondences/Attachments) -- re-run once more after Migrate() below via
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
@@ -1116,6 +1147,24 @@ public static class StartupInitializationExtensions
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"))
{
using var cmd = conn.CreateCommand();