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'));