diff --git a/Apis/api-models/Requests/JobMatchRequest.cs b/Apis/api-models/Requests/JobMatchRequest.cs index 9aec051..35f9013 100644 --- a/Apis/api-models/Requests/JobMatchRequest.cs +++ b/Apis/api-models/Requests/JobMatchRequest.cs @@ -8,4 +8,6 @@ public sealed class JobMatchRequest public bool GdprConsent { get; set; } public string? Email { get; set; } public string? CaptchaToken { get; set; } + /// ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en". + public string? Language { get; set; } } diff --git a/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs b/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs index c3b837c..d3366da 100644 --- a/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs +++ b/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs @@ -7,5 +7,7 @@ public string? JobDescription { get; set; } public bool GdprConsent { get; set; } public string? Email { get; set; } + /// ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en". + public string? Language { get; set; } } } diff --git a/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs b/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs index 2776358..d86a1b0 100644 --- a/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs +++ b/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs @@ -5,6 +5,7 @@ public sealed class CvMatchResultEntity public string Id { get; set; } = string.Empty; public string CvDocumentId { get; set; } = string.Empty; public string JobDocumentId { get; set; } = string.Empty; + public string Language { get; set; } = "en"; public string ResultJson { get; set; } = string.Empty; public int Score { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs b/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs index c4c4493..e128a69 100644 --- a/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs +++ b/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs @@ -5,8 +5,8 @@ namespace Api.Data.Repositories.Contracts; public interface IMatcherRepository { Task InitializeAsync(CancellationToken ct); - Task GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct); - Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, JobMatchResponse response, CancellationToken ct); + Task GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct); + Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct); Task GetChatCompletionAsync(string cacheKey, CancellationToken ct); Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct); } diff --git a/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs b/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs index c81618b..5ed9b0b 100644 --- a/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs +++ b/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs @@ -24,11 +24,11 @@ public sealed class EfMatcherRepository : IMatcherRepository //await _db.Database.EnsureCreatedAsync(ct); } - public async Task GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct) + public async Task GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct) { var json = await _db.CvMatchResults .AsNoTracking() - .Where(x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId) + .Where(x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language) .Select(x => x.ResultJson) .FirstOrDefaultAsync(ct); @@ -39,10 +39,10 @@ public sealed class EfMatcherRepository : IMatcherRepository return result; } - public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, JobMatchResponse response, CancellationToken ct) + public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct) { var exists = await _db.CvMatchResults.AnyAsync( - x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId, + x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language, ct); if (exists) return; @@ -52,6 +52,7 @@ public sealed class EfMatcherRepository : IMatcherRepository Id = Guid.NewGuid().ToString("N"), CvDocumentId = cvDocumentId, JobDocumentId = jobDocumentId, + Language = language, ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)), Score = response.Score, CreatedAt = DateTime.UtcNow diff --git a/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs b/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs new file mode 100644 index 0000000..bf50633 --- /dev/null +++ b/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs @@ -0,0 +1,99 @@ +// +using System; +using Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Api.Migrations +{ + [DbContext(typeof(CvMatcherDbContext))] + [Migration("20260524140335_AddLanguageToCvMatchResult")] + partial class AddLanguageToCvMatchResult + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvMatcher") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId") + .IsUnique(); + + b.ToTable("Results", "cvMatcher"); + }); + + modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b => + { + b.Property("CacheKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("ResponseText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Temperature") + .HasColumnType("decimal(4,2)"); + + b.HasKey("CacheKey"); + + b.ToTable("ChatCache", "cvMatcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.cs b/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.cs new file mode 100644 index 0000000..c711c23 --- /dev/null +++ b/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Api.Migrations +{ + /// + public partial class AddLanguageToCvMatchResult : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Language", + schema: "cvMatcher", + table: "Results", + type: "nvarchar(max)", + nullable: false, + defaultValue: "en"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Language", + schema: "cvMatcher", + table: "Results"); + } + } +} diff --git a/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs index 9a435fa..af0e900 100644 --- a/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -44,6 +44,10 @@ namespace Api.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("ResultJson") .IsRequired() .HasColumnType("nvarchar(max)"); diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 43d5bff..b3e8e3a 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -69,7 +69,7 @@ public sealed class CvMatcherService : ICvMatcherService { 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)); + jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, NormalizeLanguage(null), ct)); } return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs }; @@ -98,21 +98,23 @@ public sealed class CvMatcherService : ICvMatcherService .FirstOrDefault(x => x.DocumentId == job.DocumentId)? .MatchedChunks.Select(x => x.Text).ToArray() ?? []; - return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, ct); + return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, NormalizeLanguage(request.Language), ct); } - private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, CancellationToken ct) + private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, string language, CancellationToken ct) { - var cached = await _repository.GetMatchAsync(cv.Id, job.Id, ct); + var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, 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); + var languageName = LanguageName(language); - const string systemPrompt = """ + var 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. + Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}. JSON shape: {"score":number,"summary":"...","strengths":["..."],"gaps":["..."],"recommendations":["..."],"evidence":["..."]} """; @@ -132,7 +134,7 @@ public sealed class CvMatcherService : ICvMatcherService result.JobDocumentId = job.Id; result.JobUrl = job.SourceUrl; result.Cached = false; - await _repository.SaveMatchAsync(cv.Id, job.Id, result, ct); + await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct); //await _email.SendMatchAsync( // email, @@ -175,6 +177,16 @@ public sealed class CvMatcherService : ICvMatcherService return first ?? "Job description"; } + private static string NormalizeLanguage(string? language) => + string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim(); + + private static string LanguageName(string language) => language switch + { + "ro" => "Romanian", + "en" => "English", + _ => "English" + }; + private static string Limit(string value, int max) => value.Length <= max ? value : value[..max]; //private static string BuildEmailBody(RagDocumentDetails cv, RagDocumentDetails job, JobMatchResponse result) => $""" diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js index 59b0890..46839eb 100644 --- a/web/wwwroot/js/main.js +++ b/web/wwwroot/js/main.js @@ -582,7 +582,8 @@ jobDescription: jobDescription, email: matchEmail, gdprConsent: consent, - captchaToken: matchToken + captchaToken: matchToken, + language: currentLang() }) }); if (matchResponse.status === 429) throw new Error(t('cv.rateLimited'));