Respect UI language in match result — LLM responds in user's selected language
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 <noreply@anthropic.com>
This commit is contained in:
@@ -8,4 +8,6 @@ public sealed class JobMatchRequest
|
|||||||
public bool GdprConsent { get; set; }
|
public bool GdprConsent { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? CaptchaToken { get; set; }
|
public string? CaptchaToken { get; set; }
|
||||||
|
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
|
||||||
|
public string? Language { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,7 @@
|
|||||||
public string? JobDescription { get; set; }
|
public string? JobDescription { get; set; }
|
||||||
public bool GdprConsent { get; set; }
|
public bool GdprConsent { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
|
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
|
||||||
|
public string? Language { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public sealed class CvMatchResultEntity
|
|||||||
public string Id { get; set; } = string.Empty;
|
public string Id { get; set; } = string.Empty;
|
||||||
public string CvDocumentId { get; set; } = string.Empty;
|
public string CvDocumentId { get; set; } = string.Empty;
|
||||||
public string JobDocumentId { 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 string ResultJson { get; set; } = string.Empty;
|
||||||
public int Score { get; set; }
|
public int Score { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ namespace Api.Data.Repositories.Contracts;
|
|||||||
public interface IMatcherRepository
|
public interface IMatcherRepository
|
||||||
{
|
{
|
||||||
Task InitializeAsync(CancellationToken ct);
|
Task InitializeAsync(CancellationToken ct);
|
||||||
Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct);
|
Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct);
|
||||||
Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, JobMatchResponse response, CancellationToken ct);
|
Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct);
|
||||||
Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct);
|
Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct);
|
||||||
Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct);
|
Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
|||||||
//await _db.Database.EnsureCreatedAsync(ct);
|
//await _db.Database.EnsureCreatedAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct)
|
public async Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var json = await _db.CvMatchResults
|
var json = await _db.CvMatchResults
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId)
|
.Where(x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language)
|
||||||
.Select(x => x.ResultJson)
|
.Select(x => x.ResultJson)
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
@@ -39,10 +39,10 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
|||||||
return result;
|
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(
|
var exists = await _db.CvMatchResults.AnyAsync(
|
||||||
x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId,
|
x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language,
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
if (exists) return;
|
if (exists) return;
|
||||||
@@ -52,6 +52,7 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
|||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
CvDocumentId = cvDocumentId,
|
CvDocumentId = cvDocumentId,
|
||||||
JobDocumentId = jobDocumentId,
|
JobDocumentId = jobDocumentId,
|
||||||
|
Language = language,
|
||||||
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
||||||
Score = response.Score,
|
Score = response.Score,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("JobDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ResultJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CvDocumentId", "JobDocumentId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Results", "cvMatcher");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("CacheKey")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("nvarchar(120)");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Temperature")
|
||||||
|
.HasColumnType("decimal(4,2)");
|
||||||
|
|
||||||
|
b.HasKey("CacheKey");
|
||||||
|
|
||||||
|
b.ToTable("ChatCache", "cvMatcher");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLanguageToCvMatchResult : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Language",
|
||||||
|
schema: "cvMatcher",
|
||||||
|
table: "Results",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "en");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Language",
|
||||||
|
schema: "cvMatcher",
|
||||||
|
table: "Results");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@ namespace Api.Migrations
|
|||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ResultJson")
|
b.Property<string>("ResultJson")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public sealed class CvMatcherService : ICvMatcherService
|
|||||||
{
|
{
|
||||||
var job = await _rag.GetDocumentAsync(result.DocumentId, ct);
|
var job = await _rag.GetDocumentAsync(result.DocumentId, ct);
|
||||||
if (job is null) continue;
|
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 };
|
return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs };
|
||||||
@@ -98,21 +98,23 @@ public sealed class CvMatcherService : ICvMatcherService
|
|||||||
.FirstOrDefault(x => x.DocumentId == job.DocumentId)?
|
.FirstOrDefault(x => x.DocumentId == job.DocumentId)?
|
||||||
.MatchedChunks.Select(x => x.Text).ToArray() ?? [];
|
.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<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> evidenceChunks, string? email, CancellationToken ct)
|
private async Task<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> 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;
|
if (cached is not null) return cached;
|
||||||
|
|
||||||
var cvText = Limit(cv.Text, 18000);
|
var cvText = Limit(cv.Text, 18000);
|
||||||
var jobText = Limit(job.Text, 14000);
|
var jobText = Limit(job.Text, 14000);
|
||||||
var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000);
|
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.
|
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.
|
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":["..."]}
|
JSON shape: {"score":number,"summary":"...","strengths":["..."],"gaps":["..."],"recommendations":["..."],"evidence":["..."]}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -132,7 +134,7 @@ public sealed class CvMatcherService : ICvMatcherService
|
|||||||
result.JobDocumentId = job.Id;
|
result.JobDocumentId = job.Id;
|
||||||
result.JobUrl = job.SourceUrl;
|
result.JobUrl = job.SourceUrl;
|
||||||
result.Cached = false;
|
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(
|
//await _email.SendMatchAsync(
|
||||||
// email,
|
// email,
|
||||||
@@ -175,6 +177,16 @@ public sealed class CvMatcherService : ICvMatcherService
|
|||||||
return first ?? "Job description";
|
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 Limit(string value, int max) => value.Length <= max ? value : value[..max];
|
||||||
|
|
||||||
//private static string BuildEmailBody(RagDocumentDetails cv, RagDocumentDetails job, JobMatchResponse result) => $"""
|
//private static string BuildEmailBody(RagDocumentDetails cv, RagDocumentDetails job, JobMatchResponse result) => $"""
|
||||||
|
|||||||
@@ -582,7 +582,8 @@
|
|||||||
jobDescription: jobDescription,
|
jobDescription: jobDescription,
|
||||||
email: matchEmail,
|
email: matchEmail,
|
||||||
gdprConsent: consent,
|
gdprConsent: consent,
|
||||||
captchaToken: matchToken
|
captchaToken: matchToken,
|
||||||
|
language: currentLang()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (matchResponse.status === 429) throw new Error(t('cv.rateLimited'));
|
if (matchResponse.status === 429) throw new Error(t('cv.rateLimited'));
|
||||||
|
|||||||
Reference in New Issue
Block a user