Merge pull request 'feat: language-aware match results + full controller documentation' (#13) from main into staging
Build and Push Docker Images Staging / build (push) Successful in 3m17s
Build and Push Docker Images Staging / build (push) Successful in 3m17s
Merge PR #13: feat: language-aware match results + full controller documentation
This commit was merged in pull request #13.
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