Add Email and ClientIpAddress audit fields to cvMatcher.Results
Threads the caller's email and client IP through the match pipeline so every Results row records who triggered the match and from where. Closes #45 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,4 +10,6 @@ public sealed class JobMatchRequest
|
||||
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; }
|
||||
/// <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" });
|
||||
}
|
||||
|
||||
request.ClientIpAddress = userIp;
|
||||
_logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}",
|
||||
request.CvDocumentId,
|
||||
!string.IsNullOrWhiteSpace(request.JobUrl),
|
||||
|
||||
@@ -9,5 +9,7 @@
|
||||
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; }
|
||||
/// <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);
|
||||
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 };
|
||||
@@ -107,7 +107,7 @@ 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, NormalizeLanguage(request.Language), ct);
|
||||
return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, request.ClientIpAddress, NormalizeLanguage(request.Language), ct);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// When no evidence chunks are available from the vector search, falls back to the raw job text.
|
||||
/// </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);
|
||||
if (cached is not null) return cached;
|
||||
@@ -145,7 +145,7 @@ public sealed class CvMatcherService : ICvMatcherService
|
||||
result.JobDocumentId = job.Id;
|
||||
result.JobUrl = job.SourceUrl;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ public sealed class CvMatcherDbContext : DbContext
|
||||
entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.ResultJson).IsRequired();
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,4 +9,6 @@ public sealed class CvMatchResultEntity : BaseEntity
|
||||
public string Language { get; set; } = "en";
|
||||
public string ResultJson { get; set; } = string.Empty;
|
||||
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)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("ClientIpAddress")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("nvarchar(45)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
@@ -70,6 +74,10 @@ namespace CvMatcher.Data.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("JobDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
|
||||
@@ -6,7 +6,7 @@ public interface IMatcherRepository
|
||||
{
|
||||
Task InitializeAsync(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 SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
||||
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(
|
||||
x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language,
|
||||
@@ -58,6 +58,8 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
||||
Language = language,
|
||||
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
||||
Score = response.Score,
|
||||
Email = email,
|
||||
ClientIpAddress = clientIpAddress,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user