5eb9b3cb96
Keep external providers behind server consent, task, and prompt-cost gates while persisting actual provider provenance.
270 lines
14 KiB
C#
270 lines
14 KiB
C#
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}";
|
||
AiGenerationResult? generation;
|
||
try
|
||
{
|
||
generation = await _ai.GenerateSectionWithMetadataAsync(prompt, source, max, 120, ct);
|
||
}
|
||
catch (AiGenerationException)
|
||
{
|
||
throw new AiUnavailableException("The AI service could not generate this right now. Please try again in a moment.");
|
||
}
|
||
var result = generation?.Text;
|
||
if (string.IsNullOrWhiteSpace(result))
|
||
{
|
||
throw new AiUnavailableException("The AI service could not generate this right now. Please try again in a moment.");
|
||
}
|
||
|
||
var actualProvider = string.IsNullOrWhiteSpace(generation?.Provider) ? provider : generation.Provider;
|
||
|
||
var interaction = new AiInteraction
|
||
{
|
||
OwnerUserId = ownerUserId,
|
||
JobApplicationId = jobApplicationId,
|
||
Module = module,
|
||
Mode = mode,
|
||
Title = title,
|
||
Provider = string.IsNullOrWhiteSpace(actualProvider) ? "ai-service" : actualProvider,
|
||
ResultJson = JsonSerializer.Serialize(new
|
||
{
|
||
text = result.Trim(),
|
||
meta = new
|
||
{
|
||
model = generation?.Model,
|
||
fallbackReason = generation?.FallbackReason,
|
||
routeReason = generation?.RouteReason,
|
||
},
|
||
}, 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 _db.Database.IsSqlite()
|
||
? (await q.ToListAsync(ct)).OrderByDescending(x => x.CreatedAtUtc).ToList()
|
||
: 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** (2–3 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.";
|
||
}
|