From fc132273f73edb5066f23e85422fad65e7d4dde9 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 19 Jul 2026 17:07:00 +0200 Subject: [PATCH] feat(ai): include application intelligence in interview preparation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interview generation saw only the profile and the advert, so it produced generic questions. It now also receives what the workspace already computed: seniority, employment type, key requirements and advert technologies from the job analysis, plus the match score, the skills the candidate demonstrably has, the most relevant experience and projects — and above all the gaps, which is exactly what an interviewer probes. No second pipeline. The context comes from ApplicationIntelligenceService, which is deterministic and read-only, so this adds no AI call and cannot alter user data. Generation still runs through AiWorkspaceService and is still appended to AiInteraction. The dependency is optional, so existing constructions keep working and a missing intelligence service degrades to the previous prompt instead of failing. Only the interview module is affected; job-analysis, career-match, cover-letter and application-review assemble exactly as before. Suggestion-only is unchanged and now pinned by tests: generation adds an AiInteraction and nothing else, creates no InterviewPrepItem, leaves existing prep items and the CareerProfile untouched, and refuses another user's application. Context is scoped to the requesting user, so another user's profile is never scored in. 379 backend tests pass. Co-Authored-By: Claude Opus 4.8 --- JobTrackerApi.Tests/AiWorkspaceTests.cs | 3 + .../InterviewAiContextTests.cs | 226 ++++++++++++++++++ JobTrackerApi/Services/AiWorkspaceService.cs | 60 ++++- 3 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 JobTrackerApi.Tests/InterviewAiContextTests.cs diff --git a/JobTrackerApi.Tests/AiWorkspaceTests.cs b/JobTrackerApi.Tests/AiWorkspaceTests.cs index d4b85f3..52a3e6d 100644 --- a/JobTrackerApi.Tests/AiWorkspaceTests.cs +++ b/JobTrackerApi.Tests/AiWorkspaceTests.cs @@ -14,10 +14,13 @@ public sealed class AiWorkspaceTests public string? Next = "## Result\nGenerated suggestion."; public int Calls; public string? LastInstruction; + // The source text the module assembled — what the prompt actually saw. + public string? LastText; public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) { Calls++; LastInstruction = instruction; + LastText = text; return Task.FromResult(Next); } public Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next); diff --git a/JobTrackerApi.Tests/InterviewAiContextTests.cs b/JobTrackerApi.Tests/InterviewAiContextTests.cs new file mode 100644 index 0000000..1898032 --- /dev/null +++ b/JobTrackerApi.Tests/InterviewAiContextTests.cs @@ -0,0 +1,226 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +// Interview AI context integration. The interview module now sees what the workspace already computed +// — requirements, matched skills, gaps, relevant experience — so its questions are about THIS +// application rather than generic. The context is deterministic and read-only, so this adds no second +// AI pipeline and cannot change the user's data. +public sealed class InterviewAiContextTests +{ + private sealed class FakeAi : ISummarizerService + { + public string? Next = "## Likely questions\nSomething specific."; + public string? LastText; + public int Calls; + + public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) + { + Calls++; + LastText = text; + return Task.FromResult(Next); + } + + public Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next); + public Task ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task GetMetricsAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); + } + + private const string Advert = """ + Senior Backend Developer, full-time, Oslo. + + We expect: + - Strong experience with C# and .NET + - Solid SQL knowledge + - Experience with Kubernetes in production + """; + + private static (JobTrackerContext db, AiWorkspaceService svc, FakeAi ai) New(string userId, bool withIntelligence = true) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + var currentUser = new Mock(); + currentUser.SetupGet(s => s.UserId).Returns(userId); + var db = new JobTrackerContext(options, currentUser.Object); + var ai = new FakeAi(); + var intelligence = withIntelligence ? new ApplicationIntelligenceService(db, new JobCvMatchService()) : null; + return (db, new AiWorkspaceService(db, ai, intelligence), ai); + } + + private static async Task 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 Backend Developer", + Status = "Interview", + Description = Advert, + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return job; + } + + private static async Task SeedProfileAsync(JobTrackerContext db, string owner) + { + db.CareerProfiles.Add(new CareerProfile + { + OwnerUserId = owner, + Experiences = + { + new CareerExperience + { + OwnerUserId = owner, Title = "Backend Developer", Company = "Initech", Start = "2021", IsCurrent = true, + BulletsJson = """["Built services in C# and .NET","Owned the SQL migration programme"]""", + }, + }, + Projects = + { + new CareerProject { OwnerUserId = owner, Name = "Deploy pipeline", Role = "Author", BulletsJson = """["C# tooling"]""" }, + }, + }); + await db.SaveChangesAsync(); + } + + private static AiGenerateRequest Interview() => new("interview", null, null); + + [Fact] + public async Task Interview_generation_includes_the_job_analysis_context() + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + Assert.Contains("APPLICATION INTELLIGENCE", ai.LastText); + Assert.Contains("Seniority: Senior", ai.LastText); + Assert.Contains("Key requirements", ai.LastText); + Assert.Contains("Technologies in the advert", ai.LastText); + } + + [Fact] + public async Task Interview_generation_includes_the_career_match_context_and_the_gaps() + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + Assert.Contains("Match score:", ai.LastText); + Assert.Contains("Skills the candidate demonstrably has", ai.LastText); + // The gaps are the whole point — an interviewer probes what is missing. + Assert.Contains("Gaps the candidate must be ready to address", ai.LastText); + Assert.Contains("Kubernetes", ai.LastText); + Assert.Contains("Most relevant experience", ai.LastText); + Assert.Contains("Backend Developer", ai.LastText); + } + + [Fact] + public async Task Other_modules_are_unchanged() + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + + foreach (var module in new[] { "job-analysis", "career-match", "cover-letter", "application-review" }) + { + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", new AiGenerateRequest(module, null, null), "test", default); + Assert.DoesNotContain("APPLICATION INTELLIGENCE", ai.LastText); + } + } + + [Fact] + public async Task Generation_still_works_without_the_intelligence_service() + { + // The dependency is optional, so an older construction path degrades to the previous prompt + // rather than failing. + var (db, svc, ai) = New("user-1", withIntelligence: false); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + + var interaction = await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + Assert.NotNull(interaction); + Assert.DoesNotContain("APPLICATION INTELLIGENCE", ai.LastText); + Assert.Contains("JOB ADVERT", ai.LastText); + } + + [Fact] + public async Task Context_is_scoped_to_the_requesting_user() + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-2"); // another user's profile must never be scored in + + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + Assert.DoesNotContain("Initech", ai.LastText); + Assert.DoesNotContain("Match score:", ai.LastText); + } + + [Fact] + public async Task Another_users_application_cannot_be_generated_for() + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + var other = await SeedJobAsync(db, "user-2"); + + Assert.Null(await svc.GenerateAsync("user-1", other.Id, "profile text", "Ada", Interview(), "test", default)); + Assert.Equal(0, ai.Calls); + } + + [Fact] + public async Task Generation_writes_history_only_and_never_the_users_content() + { + var (db, svc, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + var profileBefore = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync(); + var descriptionBefore = job.Description; + + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + // The only new row is the AiInteraction. No prep item is created — the user must accept one. + Assert.Equal(1, await db.AiInteractions.CountAsync()); + Assert.Equal(0, await db.InterviewPrepItems.CountAsync()); + + var profileAfter = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync(); + Assert.Equal(profileBefore.Version, profileAfter.Version); + Assert.Equal(profileBefore.Experiences[0].BulletsJson, profileAfter.Experiences[0].BulletsJson); + Assert.Equal(descriptionBefore, (await db.JobApplications.AsNoTracking().FirstAsync(j => j.Id == job.Id)).Description); + } + + [Fact] + public async Task Existing_prep_items_are_never_overwritten_by_generation() + { + var (db, svc, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + var prep = new InterviewPrepService(db); + var mine = await prep.AddAsync("user-1", job.Id, + new InterviewPrepInput(InterviewPrepCategories.Star, "My STAR example", "My own words", null, null), default); + + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + var after = await prep.GetAsync("user-1", job.Id, default); + Assert.Equal(1, after!.Total); + Assert.Equal("My own words", after.Groups[0].Items[0].Content); + Assert.Equal(mine!.Id, after.Groups[0].Items[0].Id); + } +} diff --git a/JobTrackerApi/Services/AiWorkspaceService.cs b/JobTrackerApi/Services/AiWorkspaceService.cs index 7d79d7e..8c0e2a3 100644 --- a/JobTrackerApi/Services/AiWorkspaceService.cs +++ b/JobTrackerApi/Services/AiWorkspaceService.cs @@ -46,11 +46,59 @@ public sealed class AiWorkspaceService : IAiWorkspaceService private readonly JobTrackerContext _db; private readonly ISummarizerService _ai; + private readonly IApplicationIntelligenceService? _intelligence; - public AiWorkspaceService(JobTrackerContext db, ISummarizerService ai) + // Optional on purpose: every existing construction of this service keeps working unchanged, and a + // missing intelligence service degrades to the previous prompt rather than failing generation. + public AiWorkspaceService(JobTrackerContext db, ISummarizerService ai, IApplicationIntelligenceService? intelligence = null) { _db = db; _ai = ai; + _intelligence = intelligence; + } + + // The deterministic workspace output, formatted for the prompt. Read-only: AnalyzeAsync and + // MatchAsync own no data and write nothing, so this cannot touch the profile or the application. + private async Task BuildIntelligenceContextAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + var analysis = await _intelligence!.AnalyzeAsync(ownerUserId, jobApplicationId, ct); + var match = await _intelligence.MatchAsync(ownerUserId, jobApplicationId, ct); + if (analysis is null && match is null) return string.Empty; + + var sb = new System.Text.StringBuilder(); + sb.Append("\n\nAPPLICATION INTELLIGENCE (already computed — use it, do not restate it):"); + + if (analysis is not null) + { + Line(sb, "Seniority", analysis.Seniority); + Line(sb, "Employment type", analysis.EmploymentType); + List(sb, "Key requirements", analysis.ImportantRequirements); + List(sb, "Technologies in the advert", analysis.Technologies); + } + + if (match is { HasCareerProfile: true }) + { + sb.Append($"\nMatch score: {match.Score}% ({match.Band})"); + List(sb, "Skills the candidate demonstrably has", match.MatchedSkills); + // The gaps are the point: this is where an interviewer will probe. + List(sb, "Gaps the candidate must be ready to address", match.MissingSkills); + List(sb, "Most relevant experience", + match.RelevantExperience.Select(e => e.Subtitle is null ? e.Title : $"{e.Title} ({e.Subtitle})").ToList()); + List(sb, "Most relevant projects", match.RelevantProjects.Select(p => p.Title).ToList()); + } + + return sb.ToString(); + + static void Line(System.Text.StringBuilder sb, string label, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) sb.Append($"\n{label}: {value}"); + } + + static void List(System.Text.StringBuilder sb, string label, IReadOnlyList values) + { + if (values.Count == 0) return; + sb.Append($"\n{label}: {string.Join("; ", values.Take(8))}"); + } } public async Task GenerateAsync(string ownerUserId, int jobApplicationId, string profileText, string candidateName, AiGenerateRequest req, string provider, CancellationToken ct) @@ -67,12 +115,20 @@ public sealed class AiWorkspaceService : IAiWorkspaceService var mode = NormalizeMode(module, req.Mode); var extra = string.IsNullOrWhiteSpace(req.ExtraContext) ? string.Empty : $"\n\nAdditional user context:\n{req.ExtraContext.Trim()}"; + // Interview prep is the module that benefits most from what the workspace already computed: + // asking for likely questions without the requirements, the matched skills and — above all — + // the gaps produces generic output. Everything here is deterministic and already on screen, so + // this adds context, not another AI call. Null when unavailable, and the prompt is unchanged. + var intelligence = module == "interview" && _intelligence is not null + ? await BuildIntelligenceContextAsync(ownerUserId, jobApplicationId, ct) + : string.Empty; + var (instruction, source, title, max) = module switch { "job-analysis" => (JobAnalysisPrompt(), jobText + extra, "Job analysis", 1000), "career-match" => (CareerMatchPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Career match", 1000), "cover-letter" => (CoverLetterPrompt(mode!, candidateName), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", $"Cover letter · {Capitalize(mode!)}", 900), - "interview" => (InterviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Interview prep", 1100), + "interview" => (InterviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{intelligence}{extra}", "Interview prep", 1100), "application-review" => (ApplicationReviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Application review", 900), _ => throw new ArgumentException($"Unknown AI module '{module}'."), };