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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user