feat: persist interview prep instead of regenerating on every open

Interview prep re-ran its AI call every time the tab opened -- flagged
in the product teardown as work evaporating on every re-open (cost,
latency, and non-determinism for no reason). GetInterviewPrep now
persists one note per job application and reuses it on subsequent
reads, only regenerating when the selected attachment context changes
or a refresh is explicitly requested.

- InterviewPrepNote: one row per (owner, job), keyed additionally by
  an attachment-selection fingerprint so picking different attachments
  correctly triggers a fresh brief without needing an explicit flag.
- GetInterviewPrep gained a `refresh` query param; the frontend adds a
  small "Regenerate" button as the explicit escape hatch for when the
  underlying job/notes have changed since the note was written.
- Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects.
- 3 new tests: reuse across calls, refresh regenerates, attachment
  context change regenerates. Verified against the real dev DB.
This commit is contained in:
cesnimd
2026-07-12 15:34:07 +02:00
committed by cesnimda
parent 102938c28b
commit 5916f09852
6 changed files with 287 additions and 2 deletions
@@ -2398,13 +2398,31 @@ Candidate master CV:
}
[HttpGet("{id:int}/interview-prep")]
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
if (!refresh && userId is not null)
{
var existing = await _db.InterviewPrepNotes.FirstOrDefaultAsync(
x => x.OwnerUserId == userId && x.JobApplicationId == id && x.AttachmentContextSignature == attachmentSignature,
cancellationToken);
if (existing is not null)
{
return Ok(new InterviewPrepDto(
existing.Summary,
JsonSerializer.Deserialize<List<string>>(existing.TalkingPointsJson) ?? new List<string>(),
JsonSerializer.Deserialize<List<string>>(existing.LikelyQuestionsJson) ?? new List<string>(),
JsonSerializer.Deserialize<List<string>>(existing.WeakSpotsJson) ?? new List<string>()));
}
}
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, attachmentContext?.Context }
.Where(x => !string.IsNullOrWhiteSpace(x)));
@@ -2423,9 +2441,34 @@ Candidate master CV:
180,
70) ?? "Prepare concise, outcome-focused stories that match the core role requirements.";
if (userId is not null)
{
var note = await _db.InterviewPrepNotes.FirstOrDefaultAsync(x => x.OwnerUserId == userId && x.JobApplicationId == id, cancellationToken);
if (note is null)
{
note = new InterviewPrepNote { OwnerUserId = userId, JobApplicationId = id };
_db.InterviewPrepNotes.Add(note);
}
note.AttachmentContextSignature = attachmentSignature;
note.Summary = summary;
note.TalkingPointsJson = JsonSerializer.Serialize(talkingPoints);
note.LikelyQuestionsJson = JsonSerializer.Serialize(likelyQuestions);
note.WeakSpotsJson = JsonSerializer.Serialize(weakSpots);
note.GeneratedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
}
return Ok(new InterviewPrepDto(summary, talkingPoints, likelyQuestions, weakSpots));
}
private static string NormalizeAttachmentIdsSignature(string? attachmentIds)
{
if (string.IsNullOrWhiteSpace(attachmentIds)) return string.Empty;
var ids = attachmentIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.OrderBy(x => x, StringComparer.Ordinal);
return string.Join(",", ids);
}
[HttpGet("{id:int}/readiness")]
public async Task<ActionResult<ReadinessDto>> GetReadiness([FromRoute] int id, CancellationToken cancellationToken)
{
@@ -657,11 +657,35 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");""");
}
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5):
// stop re-running the AI call on every tab open by persisting the last generated
// note per job, keyed by the attachment selection it was generated from.
static void EnsureInterviewPrepNotesTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "InterviewPrepNotes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepNotes" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"AttachmentContextSignature" TEXT NOT NULL,
"Summary" TEXT NOT NULL,
"TalkingPointsJson" TEXT NOT NULL,
"LikelyQuestionsJson" TEXT NOT NULL,
"WeakSpotsJson" TEXT NOT NULL,
"GeneratedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_InterviewPrepNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId" ON "InterviewPrepNotes" ("OwnerUserId", "JobApplicationId");""");
}
EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
EnsureCvTables(conn);
EnsureCareerProfileTables(conn);
EnsureInterviewPrepNotesTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
@@ -804,6 +828,15 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepNotes", "Id");
if (!MySqlIndexExists(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_InterviewPrepNotes_OwnerUserId_JobApplicationId` ON `InterviewPrepNotes` (`OwnerUserId`, `JobApplicationId`);";
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.
@@ -1063,6 +1096,26 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
if (!HasMySqlTable(conn, "InterviewPrepNotes"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepNotes` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`AttachmentContextSignature` longtext NOT NULL,
`Summary` longtext NOT NULL,
`TalkingPointsJson` longtext NOT NULL,
`LikelyQuestionsJson` longtext NOT NULL,
`WeakSpotsJson` longtext NOT NULL,
`GeneratedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_InterviewPrepNotes_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();