feat(ai): include application intelligence in interview preparation
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string> 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<string> values)
|
||||
{
|
||||
if (values.Count == 0) return;
|
||||
sb.Append($"\n{label}: {string.Join("; ", values.Take(8))}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AiInteraction?> 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}'."),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user