using System.Text.Json; using Api.Requests; using Api.Responses; using Api.Services.Contracts; using Api.Settings; using Microsoft.Extensions.Options; namespace Api.Services; public sealed class CvMatcherService : ICvMatcherService { private readonly IRagApiClient _rag; private readonly IJobTextExtractor _jobTextExtractor; private readonly IMatcherAiClient _ai; private readonly IMatcherRepository _repository; private readonly IEmailService _email; private readonly MatcherSettings _settings; public CvMatcherService( IRagApiClient rag, IJobTextExtractor jobTextExtractor, IMatcherAiClient ai, IMatcherRepository repository, IEmailService email, IOptions options) { _rag = rag; _jobTextExtractor = jobTextExtractor; _ai = ai; _repository = repository; _email = email; _settings = options.Value; } public async Task UploadCvAsync(IFormFile file, bool gdprConsent, CancellationToken ct) { if (!gdprConsent) throw new InvalidOperationException("GDPR consent is required."); var response = await _rag.IndexCvPdfAsync(file, ct); return new CvUploadResponse { DocumentId = response.DocumentId, TextHash = response.TextHash, DocumentType = response.DocumentType, Title = response.Title, Chunks = response.Chunks, Characters = response.Characters, Cached = response.Cached, Summary = response.Cached ? "CV already indexed. Cached data reused." : "CV indexed successfully." }; } public async Task FindJobsAsync(FindJobsRequest request, CancellationToken ct) { var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found."); if (!string.Equals(cv.DocumentType, "cv", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("The provided document is not a CV."); } var search = await _rag.SearchAsync(new RagSearchRequest { QueryText = BuildCvSearchProfile(cv.Text), TargetDocumentTypes = ["job"], TopK = request.TopK ?? _settings.TopK }, ct); var deepScoreLimit = Math.Clamp(_settings.DeepScoreTopN, 1, 10); var jobs = new List(); foreach (var result in search.Results.Take(deepScoreLimit)) { var job = await _rag.GetDocumentAsync(result.DocumentId, ct); if (job is null) continue; jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, ct)); } return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs }; } public async Task MatchJobAsync(MatchJobRequest request, CancellationToken ct) { if (!request.GdprConsent) throw new InvalidOperationException("GDPR consent is required."); if (string.IsNullOrWhiteSpace(request.CvDocumentId)) throw new InvalidOperationException("Missing CV document id."); var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found."); var jobText = await _jobTextExtractor.ExtractAsync(request.JobUrl, request.JobDescription, ct); if (jobText.Length < 80) throw new InvalidOperationException("Could not extract enough job text. Paste the job description manually."); var job = await _rag.IndexJobTextAsync(jobText, request.JobUrl, ExtractJobTitle(jobText), ct); var jobDocument = await _rag.GetDocumentAsync(job.DocumentId, ct) ?? throw new InvalidOperationException("Indexed job document not found."); var search = await _rag.SearchAsync(new RagSearchRequest { QueryText = BuildCvSearchProfile(cv.Text), TargetDocumentTypes = ["job"], TopK = Math.Max(5, _settings.TopK) }, ct); var matchedChunks = search.Results .FirstOrDefault(x => x.DocumentId == job.DocumentId)? .MatchedChunks.Select(x => x.Text).ToArray() ?? []; return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, ct); } private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, CancellationToken ct) { var cached = await _repository.GetMatchAsync(cv.Id, job.Id, ct); if (cached is not null) return cached; var cvText = Limit(cv.Text, 18000); var jobText = Limit(job.Text, 14000); var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000); const string systemPrompt = """ You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. JSON shape: {"score":number,"summary":"...","strengths":["..."],"gaps":["..."],"recommendations":["..."],"evidence":["..."]} """; var userPrompt = $""" CV: {cvText} JOB: {jobText} SEMANTICALLY MATCHED JOB EVIDENCE: {evidence} """; var json = await _ai.CreateChatCompletionAsync(systemPrompt, userPrompt, 0.2m, ct); var result = ParseResult(json); result.JobDocumentId = job.Id; result.JobUrl = job.SourceUrl; result.Cached = false; await _repository.SaveMatchAsync(cv.Id, job.Id, result, ct); await _email.SendMatchAsync( email, $"MyAi.ro CV Match: {result.Score}% - {job.Title}", BuildEmailBody(cv, job, result), ct); return result; } private static JobMatchResponse ParseResult(string json) { try { var parsed = JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web)); if (parsed is not null) return parsed; } catch { // Fall through to safe response. } return new JobMatchResponse { Score = 0, Summary = "The AI response could not be parsed as structured JSON.", Recommendations = ["Inspect the raw model output and tune the scoring prompt."] }; } private static string BuildCvSearchProfile(string cvText) { var text = Limit(cvText, 10000); return $"Candidate profile, skills, technologies, seniority, industry experience, project experience: {text}"; } private static string ExtractJobTitle(string jobText) { var first = jobText.Split('.', '\n', '\r').Select(x => x.Trim()).FirstOrDefault(x => x.Length is > 8 and < 140); return first ?? "Job description"; } private static string Limit(string value, int max) => value.Length <= max ? value : value[..max]; private static string BuildEmailBody(RagDocumentDetails cv, RagDocumentDetails job, JobMatchResponse result) => $""" CV Matcher result CV: {cv.Title} Job: {job.Title} Job URL: {job.SourceUrl ?? "N/A"} Score: {result.Score}% Summary: {result.Summary} Strengths: - {string.Join("\n- ", result.Strengths)} Gaps: - {string.Join("\n- ", result.Gaps)} Recommendations: - {string.Join("\n- ", result.Recommendations)} """; }