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 GenerateAsync(string ownerUserId, int jobApplicationId, string profileText, string candidateName, AiGenerateRequest req, string provider, CancellationToken ct); Task> HistoryAsync(string ownerUserId, int jobApplicationId, string? module, CancellationToken ct); Task GetAsync(string ownerUserId, int id, CancellationToken ct); Task DeleteAsync(string ownerUserId, int id, CancellationToken ct); // The module keys this service supports (for the controller/UI to enumerate). IReadOnlyList Modules { get; } } public sealed class AiWorkspaceService : IAiWorkspaceService { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); public IReadOnlyList Modules { get; } = new[] { "job-analysis", "career-match", "cover-letter", "interview", "application-review", }; private static readonly HashSet 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 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) { 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> 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 GetAsync(string ownerUserId, int id, CancellationToken ct) => _db.AiInteractions.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct); public async Task 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."; }