This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
using System.Text.Json;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using Api.Clients.Api.Contracts;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvMatcher.Models.Settings;
|
||||
using Api.Services.Contracts;
|
||||
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 MatcherSettings _settings;
|
||||
|
||||
public CvMatcherService(
|
||||
IRagApiClient rag,
|
||||
IJobTextExtractor jobTextExtractor,
|
||||
IMatcherAiClient ai,
|
||||
IMatcherRepository repository,
|
||||
IOptions<MatcherSettings> options)
|
||||
{
|
||||
_rag = rag;
|
||||
_jobTextExtractor = jobTextExtractor;
|
||||
_ai = ai;
|
||||
_repository = repository;
|
||||
_settings = options.Value;
|
||||
}
|
||||
|
||||
public async Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
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<FindJobsResponse> 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<JobMatchResponse>();
|
||||
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<JobMatchResponse> 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<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> 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<JobMatchResponse>(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)}
|
||||
// """;
|
||||
}
|
||||
Reference in New Issue
Block a user