Add Email and ClientIpAddress audit fields to cvMatcher.Results #46
@@ -10,4 +10,6 @@ public sealed class JobMatchRequest
|
|||||||
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>
|
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
|
||||||
public string? Language { get; set; }
|
public string? Language { get; set; }
|
||||||
|
/// <summary>Client IP address — set by the api layer from the HTTP context before forwarding. Not supplied by the browser.</summary>
|
||||||
|
public string? ClientIpAddress { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ public sealed class CvMatcherController : ControllerBase
|
|||||||
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
|
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request.ClientIpAddress = userIp;
|
||||||
_logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}",
|
_logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}",
|
||||||
request.CvDocumentId,
|
request.CvDocumentId,
|
||||||
!string.IsNullOrWhiteSpace(request.JobUrl),
|
!string.IsNullOrWhiteSpace(request.JobUrl),
|
||||||
|
|||||||
@@ -9,5 +9,7 @@
|
|||||||
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>
|
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
|
||||||
public string? Language { get; set; }
|
public string? Language { get; set; }
|
||||||
|
/// <summary>Client IP address forwarded by the api layer. Null when called from a background job.</summary>
|
||||||
|
public string? ClientIpAddress { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,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, NormalizeLanguage(null), ct));
|
jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, null, NormalizeLanguage(null), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs };
|
return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs };
|
||||||
@@ -107,7 +107,7 @@ 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, NormalizeLanguage(request.Language), ct);
|
return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, request.ClientIpAddress, NormalizeLanguage(request.Language), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -115,7 +115,7 @@ public sealed class CvMatcherService : ICvMatcherService
|
|||||||
/// Returns a cached result immediately when the same (CV, job, language) triple has been scored before.
|
/// Returns a cached result immediately when the same (CV, job, language) triple has been scored before.
|
||||||
/// When no evidence chunks are available from the vector search, falls back to the raw job text.
|
/// When no evidence chunks are available from the vector search, falls back to the raw job text.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> evidenceChunks, string? email, string language, CancellationToken ct)
|
private async Task<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> evidenceChunks, string? email, string? clientIpAddress, string language, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, 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;
|
||||||
@@ -145,7 +145,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, language, result, ct);
|
await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, email, clientIpAddress, ct);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ public sealed class CvMatcherDbContext : DbContext
|
|||||||
entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired();
|
entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired();
|
||||||
entity.Property(x => x.ResultJson).IsRequired();
|
entity.Property(x => x.ResultJson).IsRequired();
|
||||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
entity.Property(x => x.Email).HasMaxLength(256);
|
||||||
|
entity.Property(x => x.ClientIpAddress).HasMaxLength(45);
|
||||||
entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique();
|
entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ public sealed class CvMatchResultEntity : BaseEntity
|
|||||||
public string Language { get; set; } = "en";
|
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 string? Email { get; set; }
|
||||||
|
public string? ClientIpAddress { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using CvMatcher.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvMatcher.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(CvMatcherDbContext))]
|
||||||
|
[Migration("20260608155310_AddEmailAndIpToResults")]
|
||||||
|
partial class AddEmailAndIpToResults
|
||||||
|
{
|
||||||
|
/// <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("CvMatcher.Data.Entities.AiPromptEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Key", "Language");
|
||||||
|
|
||||||
|
b.ToTable("AiPrompts", "cvMatcher");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("nvarchar(45)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("JobDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ResultJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Results", "cvMatcher");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvMatcher.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,45 @@
|
|||||||
|
using CvMatcher.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvMatcher.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEmailAndIpToResults : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ClientIpAddress",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "Results",
|
||||||
|
type: "nvarchar(45)",
|
||||||
|
maxLength: 45,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Email",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "Results",
|
||||||
|
type: "nvarchar(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClientIpAddress",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "Results");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Email",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "Results");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,10 @@ namespace CvMatcher.Data.Migrations
|
|||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("nvarchar(45)");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("datetime2")
|
.HasColumnType("datetime2")
|
||||||
@@ -70,6 +74,10 @@ namespace CvMatcher.Data.Migrations
|
|||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
b.Property<string>("JobDocumentId")
|
b.Property<string>("JobDocumentId")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ public interface IMatcherRepository
|
|||||||
{
|
{
|
||||||
Task InitializeAsync(CancellationToken ct);
|
Task InitializeAsync(CancellationToken ct);
|
||||||
Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct);
|
Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct);
|
||||||
Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct);
|
Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct)
|
public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var exists = await _db.CvMatchResults.AnyAsync(
|
var exists = await _db.CvMatchResults.AnyAsync(
|
||||||
x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language,
|
x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language,
|
||||||
@@ -58,6 +58,8 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
|||||||
Language = language,
|
Language = language,
|
||||||
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
||||||
Score = response.Score,
|
Score = response.Score,
|
||||||
|
Email = email,
|
||||||
|
ClientIpAddress = clientIpAddress,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user