Files
jobtrackingapp/JobTrackerApi/Services/AiWorkspaceService.cs
T
cesnimda c08232b9d7
CI and Deploy / test (push) Failing after 58s
CI and Deploy / deploy (push) Has been skipped
feat: meter AI usage
2026-07-30 22:39:24 +02:00

248 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed record AiGenerateRequest(string Module, string? Mode, string? ExtraContext);
// Thrown when the AI service returns nothing usable — the controller maps it to 502 with the reason.
public sealed class AiUnavailableException : Exception
{
public AiUnavailableException(string message) : base(message) { }
}
public interface IAiWorkspaceService
{
// Runs one module, stores the result as an append-only AiInteraction, and returns it. Never
// mutates the profile, a CV variant, or the application — suggestion only.
Task<AiInteraction?> GenerateAsync(string ownerUserId, int jobApplicationId, string profileText, string candidateName, AiGenerateRequest req, string provider, CancellationToken ct);
Task<IReadOnlyList<AiInteraction>> HistoryAsync(string ownerUserId, int jobApplicationId, string? module, CancellationToken ct);
Task<AiInteraction?> GetAsync(string ownerUserId, int id, CancellationToken ct);
Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct);
// The module keys this service supports (for the controller/UI to enumerate).
IReadOnlyList<string> Modules { get; }
}
public sealed class AiWorkspaceService : IAiWorkspaceService
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public IReadOnlyList<string> Modules { get; } = new[]
{
"job-analysis", "career-match", "cover-letter", "interview", "application-review",
};
private static readonly HashSet<string> CoverLetterModes = new(StringComparer.OrdinalIgnoreCase)
{
"professional", "friendly", "short", "detailed", "modern", "traditional",
};
private const string Guardrail =
"Preserve every factual claim — never invent employers, titles, dates, qualifications, or metrics. "
+ "This is a suggestion the user will review and edit; return only the requested content, in clean markdown, with no preamble.";
private readonly JobTrackerContext _db;
private readonly ISummarizerService _ai;
private readonly IApplicationIntelligenceService? _intelligence;
// 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)
{
var module = (req.Module ?? string.Empty).Trim().ToLowerInvariant();
if (!Modules.Contains(module)) throw new ArgumentException($"Unknown AI module '{module}'.");
var job = await _db.JobApplications.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
if (job is null) return null;
var jobText = BuildJobContext(job);
var profile = string.IsNullOrWhiteSpace(profileText) ? "(no master profile on file yet)" : profileText.Trim();
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}{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}'."),
};
var prompt = $"{instruction} {Guardrail}";
var result = await _ai.SummarizeSectionAsync(prompt, source, max, 120);
if (string.IsNullOrWhiteSpace(result))
{
throw new AiUnavailableException("The AI service could not generate this right now. Please try again in a moment.");
}
var interaction = new AiInteraction
{
OwnerUserId = ownerUserId,
JobApplicationId = jobApplicationId,
Module = module,
Mode = mode,
Title = title,
Provider = string.IsNullOrWhiteSpace(provider) ? "ai-service" : provider,
ResultJson = JsonSerializer.Serialize(new { text = result.Trim() }, Json),
InputCharacterCount = prompt.Length + source.Length,
OutputCharacterCount = result.Trim().Length,
EstimatedTokenCount = EstimateTokens(prompt.Length + source.Length + result.Trim().Length),
CreatedAtUtc = DateTimeOffset.UtcNow,
};
_db.AiInteractions.Add(interaction);
await _db.SaveChangesAsync(ct);
return interaction;
}
public async Task<IReadOnlyList<AiInteraction>> HistoryAsync(string ownerUserId, int jobApplicationId, string? module, CancellationToken ct)
{
var q = _db.AiInteractions.Where(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == jobApplicationId);
if (!string.IsNullOrWhiteSpace(module)) { var m = module.Trim().ToLowerInvariant(); q = q.Where(x => x.Module == m); }
return await q.OrderByDescending(x => x.CreatedAtUtc).ToListAsync(ct);
}
public Task<AiInteraction?> GetAsync(string ownerUserId, int id, CancellationToken ct) =>
_db.AiInteractions.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct);
public async Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct)
{
var row = await GetAsync(ownerUserId, id, ct);
if (row is null) return false;
_db.AiInteractions.Remove(row);
await _db.SaveChangesAsync(ct);
return true;
}
internal static int EstimateTokens(int characterCount) => Math.Max(0, (characterCount + 3) / 4);
private static string? NormalizeMode(string module, string? mode)
{
if (module != "cover-letter") return null;
var m = (mode ?? "professional").Trim().ToLowerInvariant();
return CoverLetterModes.Contains(m) ? m : "professional";
}
private static string BuildJobContext(JobApplication job)
{
var parts = new[]
{
Field("Role", job.JobTitle),
Field("Company", job.Company?.Name),
Field("Status", job.Status),
Field("Summary", job.ShortSummary),
Field("Description", job.Description),
Field("Translated description", job.TranslatedDescription),
Field("Notes", job.Notes),
Field("URL", job.JobUrl),
};
return string.Join("\n", parts.Where(p => p != null));
}
private static string? Field(string label, string? value) => string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value.Trim()}";
private static string Capitalize(string s) => s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..];
// --- Prompts. Each asks for markdown with clear sections; the guardrail is appended by the caller. ---
private static string JobAnalysisPrompt() =>
"Analyse this job advert. Return markdown with these sections: **Company**, **Role**, **Required skills**, "
+ "**Nice-to-have skills**, **Technologies**, **Experience**, **Education**, **Soft skills**, **Responsibilities**, "
+ "**Salary** (only if stated), **Benefits**, **Work model**, **Visa requirements**, **Language requirements**, "
+ "**Summary** (23 sentences), **Likely interview topics**, and **Confidence** (High/Medium/Low with one line on why). "
+ "Omit any field the advert does not mention rather than guessing.";
private static string CareerMatchPrompt() =>
"Compare the candidate profile against the job advert. Return markdown with: **Match** (a single percentage with one "
+ "line of reasoning), **Strengths**, **Weaknesses**, **Missing skills**, **Most relevant experience**, and "
+ "**Suggested improvements** (concrete, actionable). Base every point only on what the profile actually shows.";
private static string CoverLetterPrompt(string mode, string candidateName) =>
$"Write a cover letter for {(string.IsNullOrWhiteSpace(candidateName) ? "the candidate" : candidateName)} for this role in a "
+ $"{ModeGuidance(mode)} Ground every claim in the candidate profile; do not invent experience. Return only the letter body.";
private static string ModeGuidance(string mode) => mode switch
{
"friendly" => "warm, personable style — approachable but still professional.",
"short" => "concise style — 3 short paragraphs at most, every sentence earning its place.",
"detailed" => "thorough style — cover motivation, the strongest matching experience, and fit, without padding.",
"modern" => "modern, direct style — confident, plain language, no clichés.",
"traditional" => "traditional, formal style — conventional structure and measured tone.",
_ => "professional, confident style.",
};
private static string InterviewPrompt() =>
"Create an interview preparation brief in markdown with: **Company research summary** (from the advert only), "
+ "**Likely interview questions**, **Behavioural questions**, **Technical questions**, **Suggested STAR answers** "
+ "(outline Situation/Task/Action/Result using the candidate's real experience), and a **Preparation checklist**.";
private static string ApplicationReviewPrompt() =>
"Review this application (candidate profile as the material to be submitted, against the job advert). Return markdown "
+ "with: **Overall strength** (a one-line verdict + rating out of 10), **Missing information**, **Weak areas**, "
+ "**ATS issues** (keywords/formatting that could hurt automated screening), **Grammar & clarity**, and "
+ "**Formatting suggestions**. Be specific and constructive.";
}