From b6878e3b4529e3b510704da4fb683ddb58af4972 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 24 May 2026 17:04:21 +0300 Subject: [PATCH] =?UTF-8?q?Respect=20UI=20language=20in=20match=20result?= =?UTF-8?q?=20=E2=80=94=20LLM=20responds=20in=20user's=20selected=20langua?= =?UTF-8?q?ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend sends the active language code (currentLang()) with every match request. CvMatcherService injects a language instruction into the system prompt so the LLM returns summary, strengths, gaps, recommendations, and evidence in the correct language. The match result cache (CvMatchResults) now includes Language as part of the lookup key so Romanian and English results are stored and retrieved independently. Existing cached rows default to 'en'. Co-Authored-By: Claude Sonnet 4.6 --- Apis/api-models/Requests/JobMatchRequest.cs | 2 + .../Requests/MatchJobRequest.cs | 2 + .../Data/Entities/CvMatchResultEntity.cs | 1 + .../Contracts/IMatcherRepository.cs | 4 +- .../Data/Repositories/EfMatcherRepository.cs | 9 +- ...335_AddLanguageToCvMatchResult.Designer.cs | 99 +++++++++++++++++++ ...260524140335_AddLanguageToCvMatchResult.cs | 31 ++++++ .../CvMatcherDbContextModelSnapshot.cs | 4 + .../Services/CvMatcherService.cs | 24 +++-- web/wwwroot/js/main.js | 3 +- 10 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs create mode 100644 Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.cs 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')); -- 2.52.0