7 Commits

Author SHA1 Message Date
claude 9cf3db089d fix(cv-search-job): separate keyword badges with whitespace in results email
string.Join("") produced no whitespace between inline-block spans,
causing keywords to visually merge in email clients that collapse margins.
Switched to string.Join(" ") and zeroed left margin on each badge so
they wrap cleanly without a gap on the first item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:05:33 +03:00
claude e5b6f19c1a chore: remove orphaned project directories left over from renames
Deleted stale directories and stray .csproj files that were never added
to the solution after project renames:
- Apis/cv-search-models/  (renamed → cv-search-data)
- Apis/myai-models/       (renamed → myai-data)
- Apis/shared-models/     (empty leftover)
- Apis/cv-search-data/cv-search-models.csproj  (stray old csproj)
- Apis/myai-data/myai-models.csproj            (stray old csproj)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:49:01 +03:00
claude 9bedf57f39 fix(migrations): replace hardcoded schema strings with MigrationConstants.SchemaName
Two migration files had literal schema strings that were missed in earlier passes:
- cv-search-data AddJobSearchTables: two CreateIndex calls used "cvSearch"
- rag-data InitialRagSchema: FK principalSchema used "rag"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:46:41 +03:00
claude b78ede23cf feat(job-search): extract keywords from LLM match call instead of heuristics
Piggybacks keyword extraction onto the existing CV-to-job LLM call —
no extra API calls. The system prompt now instructs the model to return
8-12 English job-search terms (job titles, technologies, skills, domains)
in a new `keywords` field alongside the existing score/summary fields.

Keywords flow: LLM JSON → JobMatchResponse.Keywords → CreateJobSearchTokenRequest →
JobSearchTokenEntity.Keywords (stored comma-separated) → JobSearchSessionEntity.Keywords
(copied at session-creation time, no RAG call needed).

Changes:
- Add Keywords to JobMatchResponse, CreateJobSearchTokenRequest, JobSearchTokenEntity
- IJobTokenService.CreateTokenAsync now accepts IReadOnlyList<string> keywords
- JobTokenService: store keywords on token; TriggerStartAsync reads token.Keywords
  instead of fetching CV text from RAG — removes IRagApiClient dependency
- Remove heuristic ExtractKeywords method
- Migration AddKeywordsToJobSearchTokens: adds Keywords column to cvSearch.JobSearchTokens
- Migration UpdateCvMatchSystemPromptKeywords: updates ai.cv-match.system-prompt seed
  to include keywords in the JSON shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:44:13 +03:00
claude a467fac35d fix(cv-matcher-api): fix keyword extraction for single-line PDF text
PDF text extraction often stores all content without newlines. The previous
line-based splitter would produce one line > 200 chars which was filtered out,
yielding empty keywords. Replace with word-level sampling of the first 2000
chars, splitting on whitespace and common delimiters, skipping phone fragments,
emails, and URLs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:29:10 +03:00
claude 25731868ee fix(email-data): replace hardcoded emailApi schema string with MigrationConstants
Down migration was referencing "emailApi" literal instead of MigrationConstants.SchemaName,
which would have dropped the wrong schema on rollback. Also fix stale comment in DbContext.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:19:58 +03:00
claude c675954f8a fix(cv-search-data): use MigrationConstants.SchemaName in AddJobProviders migration
Replace hardcoded "cvSearch" string literals with MigrationConstants.SchemaName
in the Up, InsertData, and Down methods, consistent with all other migrations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:15:44 +03:00
33 changed files with 306 additions and 942 deletions
+1 -1
View File
@@ -181,7 +181,7 @@ public sealed class CvMatcherController : ControllerBase
try try
{ {
var tokenResp = await _jobSearchApi.CreateTokenAsync( var tokenResp = await _jobSearchApi.CreateTokenAsync(
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language }, new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords },
ct); ct);
if (!string.IsNullOrWhiteSpace(tokenResp.TokenId)) if (!string.IsNullOrWhiteSpace(tokenResp.TokenId))
{ {
@@ -5,4 +5,5 @@ public sealed class CreateJobSearchTokenRequest
public string CvDocumentId { get; set; } = string.Empty; public string CvDocumentId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
public string Language { get; set; } = "en"; public string Language { get; set; } = "en";
public List<string> Keywords { get; set; } = [];
} }
@@ -8,6 +8,7 @@
public List<string> Gaps { get; set; } = []; public List<string> Gaps { get; set; } = [];
public List<string> Recommendations { get; set; } = []; public List<string> Recommendations { get; set; } = [];
public List<string> Evidence { get; set; } = []; public List<string> Evidence { get; set; } = [];
public List<string> Keywords { get; set; } = [];
public bool Cached { get; set; } public bool Cached { get; set; }
public string? JobDocumentId { get; set; } public string? JobDocumentId { get; set; }
public string? JobUrl { get; set; } public string? JobUrl { get; set; }
@@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase
if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email)) if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email))
return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" }); return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" });
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, ct); var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, ct);
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId }); return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
} }
catch (Exception ex) catch (Exception ex)
@@ -12,12 +12,13 @@ public interface IJobTokenService
/// <param name="cvDocumentId">Identifier of the indexed CV document.</param> /// <param name="cvDocumentId">Identifier of the indexed CV document.</param>
/// <param name="email">Email address of the user who will receive the results.</param> /// <param name="email">Email address of the user who will receive the results.</param>
/// <param name="language">Preferred language for result emails (e.g. <c>"en"</c>, <c>"ro"</c>).</param> /// <param name="language">Preferred language for result emails (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
/// <param name="keywords">Job search keywords extracted by the LLM during the match call.</param>
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <returns> /// <returns>
/// The generated token ID to embed in the one-click job search link, /// The generated token ID to embed in the one-click job search link,
/// or <c>null</c> when no job providers are currently enabled (link should be suppressed). /// or <c>null</c> when no job providers are currently enabled (link should be suppressed).
/// </returns> /// </returns>
Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, CancellationToken ct);
/// <summary> /// <summary>
/// Validates the token and, if valid, marks it as used and creates a <c>Pending</c> job search session. /// Validates the token and, if valid, marks it as used and creates a <c>Pending</c> job search session.
@@ -1,6 +1,4 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using Api.Clients.Api.Contracts;
using Api.Services.Contracts; using Api.Services.Contracts;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using CvSearch.Data; using CvSearch.Data;
@@ -16,28 +14,27 @@ namespace Api.Services;
/// Provider configuration is read from <c>cvSearch.JobProviders</c> at session-creation time and /// Provider configuration is read from <c>cvSearch.JobProviders</c> at session-creation time and
/// snapshotted into <c>JobSearchSessionEntity.ProviderConfigJson</c> so subsequent config changes /// snapshotted into <c>JobSearchSessionEntity.ProviderConfigJson</c> so subsequent config changes
/// do not affect already-queued sessions. /// do not affect already-queued sessions.
/// Keywords are extracted by the LLM during the CV-to-job match call and stored on the token,
/// then copied to the session when the user clicks the link — no extra RAG call needed.
/// </summary> /// </summary>
public sealed class JobTokenService : IJobTokenService public sealed class JobTokenService : IJobTokenService
{ {
private readonly CvSearchDbContext _db; private readonly CvSearchDbContext _db;
private readonly IRagApiClient _rag;
private readonly JobSearchSettings _settings; private readonly JobSearchSettings _settings;
private readonly ILogger<JobTokenService> _logger; private readonly ILogger<JobTokenService> _logger;
public JobTokenService( public JobTokenService(
CvSearchDbContext db, CvSearchDbContext db,
IRagApiClient rag,
IOptions<JobSearchSettings> settings, IOptions<JobSearchSettings> settings,
ILogger<JobTokenService> logger) ILogger<JobTokenService> logger)
{ {
_db = db; _db = db;
_rag = rag;
_settings = settings.Value; _settings = settings.Value;
_logger = logger; _logger = logger;
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) public async Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, CancellationToken ct)
{ {
var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct); var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct);
if (!hasEnabledProviders) if (!hasEnabledProviders)
@@ -52,6 +49,7 @@ public sealed class JobTokenService : IJobTokenService
CvDocumentId = cvDocumentId, CvDocumentId = cvDocumentId,
Email = email, Email = email,
Language = language, Language = language,
Keywords = string.Join(",", keywords),
ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays),
Used = false, Used = false,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
@@ -59,7 +57,7 @@ public sealed class JobTokenService : IJobTokenService
_db.JobSearchTokens.Add(token); _db.JobSearchTokens.Add(token);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}", token.Id, cvDocumentId); _logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}", token.Id, cvDocumentId, token.Keywords);
return token.Id; return token.Id;
} }
@@ -74,8 +72,7 @@ public sealed class JobTokenService : IJobTokenService
token.Used = true; token.Used = true;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct); var keywords = token.Keywords;
var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty;
var enabledProviders = await _db.JobProviders var enabledProviders = await _db.JobProviders
.Where(p => p.Enabled) .Where(p => p.Enabled)
@@ -108,10 +105,6 @@ public sealed class JobTokenService : IJobTokenService
return StartJobSearchStatus.Started; return StartJobSearchStatus.Started;
} }
/// <summary>
/// Maps a <see cref="JobProviderEntity"/> to the <see cref="JobProviderConfig"/> DTO used by
/// <c>cv-search-job</c>. The <c>InitialKeywords</c> list is stored as a JSON array in the entity.
/// </summary>
private static JobProviderConfig ToConfig(JobProviderEntity entity) private static JobProviderConfig ToConfig(JobProviderEntity entity)
{ {
List<string> keywords; List<string> keywords;
@@ -136,29 +129,4 @@ public sealed class JobTokenService : IJobTokenService
}; };
} }
/// <summary>
/// Extracts up to 10 meaningful keywords from the CV text using simple heuristics (no LLM).
/// Takes the first 5 usable lines, splits them into words, strips punctuation, and deduplicates.
/// </summary>
private static string ExtractKeywords(string cvText)
{
var lines = cvText
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim())
.Where(l => l.Length > 5 && l.Length < 200)
// Skip lines that are purely digits, spaces, and phone/contact punctuation (phone numbers, emails, etc.)
.Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$"))
.Take(5)
.ToList();
var words = lines
.SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Select(w => Regex.Replace(w, @"[^\w\-]", ""))
.Where(w => w.Length > 2)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10)
.ToList();
return string.Join(",", words);
}
} }
@@ -0,0 +1,130 @@
// <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("20260529140000_UpdateCvMatchSystemPromptKeywords")]
partial class UpdateCvMatchSystemPromptKeywords
{
/// <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<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("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,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
using CvMatcher.Data;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class UpdateCvMatchSystemPromptKeywords : Migration
{
private const string OldPrompt =
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\n" +
"Penalize missing required skills. Do not invent experience. Use concise business language.\n" +
"Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\n" +
"JSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"]}";
private const string NewPrompt =
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\n" +
"Penalize missing required skills. Do not invent experience. Use concise business language.\n" +
"Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\n" +
"Also extract 8 to 12 English job search keywords from the CV — job titles, technologies, skills, and domains.\n" +
"The keywords array must always be in English regardless of {{languageName}}. Exclude names, emails, phone numbers, and locations.\n" +
"JSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"],\"keywords\":[\"term1\",\"term2\"]}";
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: new object[] { "ai.cv-match.system-prompt", "*" },
column: "Value",
value: NewPrompt);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: new object[] { "ai.cv-match.system-prompt", "*" },
column: "Value",
value: OldPrompt);
}
}
}
@@ -34,6 +34,7 @@ public sealed class CvSearchDbContext : DbContext
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
entity.Property(x => x.Email).HasMaxLength(256).IsRequired(); entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired(); entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired();
entity.Property(x => x.Keywords).HasMaxLength(1000).HasDefaultValue(string.Empty);
entity.Property(x => x.Used).HasDefaultValue(false); entity.Property(x => x.Used).HasDefaultValue(false);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
}); });
@@ -9,4 +9,5 @@ public sealed class JobSearchTokenEntity : BaseEntity
public string Language { get; set; } = "en"; public string Language { get; set; } = "en";
public DateTime ExpiresAt { get; set; } public DateTime ExpiresAt { get; set; }
public bool Used { get; set; } public bool Used { get; set; }
public string Keywords { get; set; } = string.Empty;
} }
@@ -73,13 +73,13 @@ namespace CvSearch.Data.Migrations
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_JobSearchResults_SessionId", name: "IX_JobSearchResults_SessionId",
schema: "cvSearch", schema: MigrationConstants.SchemaName,
table: "JobSearchResults", table: "JobSearchResults",
column: "SessionId"); column: "SessionId");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_JobSearchSessions_Status", name: "IX_JobSearchSessions_Status",
schema: "cvSearch", schema: MigrationConstants.SchemaName,
table: "JobSearchSessions", table: "JobSearchSessions",
column: "Status"); column: "Status");
} }
@@ -12,7 +12,7 @@ namespace CvSearch.Data.Migrations
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "JobProviders", name: "JobProviders",
schema: "cvSearch", schema: MigrationConstants.SchemaName,
columns: table => new columns: table => new
{ {
Id = table.Column<int>(type: "int", nullable: false) Id = table.Column<int>(type: "int", nullable: false)
@@ -33,7 +33,7 @@ namespace CvSearch.Data.Migrations
// Seed the three default providers — all disabled so the feature is opt-in per environment. // Seed the three default providers — all disabled so the feature is opt-in per environment.
// Enable a provider by setting its Enabled column to 1 via SQL or a future admin UI. // Enable a provider by setting its Enabled column to 1 via SQL or a future admin UI.
migrationBuilder.InsertData( migrationBuilder.InsertData(
schema: "cvSearch", schema: MigrationConstants.SchemaName,
table: "JobProviders", table: "JobProviders",
columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"], columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"],
values: new object[,] values: new object[,]
@@ -49,7 +49,7 @@ namespace CvSearch.Data.Migrations
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "JobProviders", name: "JobProviders",
schema: "cvSearch"); schema: MigrationConstants.SchemaName);
} }
} }
} }
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using CvSearch.Models.Data; using CvSearch.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace CvSearch.Models.Migrations namespace CvSearch.Data.Migrations
{ {
[DbContext(typeof(CvSearchDbContext))] [DbContext(typeof(CvSearchDbContext))]
[Migration("20260524145702_AddLanguageToJobSearchEntities")] [Migration("20260529130000_AddKeywordsToJobSearchTokens")]
partial class AddLanguageToJobSearchEntities partial class AddKeywordsToJobSearchTokens
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b => modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchResults", "cvSearch"); b.ToTable("JobSearchResults", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -128,7 +176,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchSessions", "cvSearch"); b.ToTable("JobSearchSessions", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -152,6 +200,13 @@ namespace CvSearch.Models.Migrations
b.Property<DateTime>("ExpiresAt") b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language") b.Property<string>("Language")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
using CvSearch.Data;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddKeywordsToJobSearchTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Keywords",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Keywords",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens");
}
}
}
@@ -197,6 +197,13 @@ namespace CvSearch.Data.Migrations
b.Property<DateTime>("ExpiresAt") b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language") b.Property<string>("Language")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>CvSearch.Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -1,160 +0,0 @@
// <auto-generated />
using System;
using CvSearch.Models.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Models.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260522093356_AddJobSearchTables")]
partial class AddJobSearchTables
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", 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>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", 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>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,102 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Models.Migrations
{
/// <inheritdoc />
public partial class AddJobSearchTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "cvSearch");
migrationBuilder.CreateTable(
name: "JobSearchResults",
schema: "cvSearch",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
SessionId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ProviderName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
JobUrl = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
JobTitle = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
JobText = table.Column<string>(type: "nvarchar(max)", nullable: false),
Score = table.Column<int>(type: "int", nullable: false),
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_JobSearchResults", x => x.Id);
});
migrationBuilder.CreateTable(
name: "JobSearchSessions",
schema: "cvSearch",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
TokenId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Keywords = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
ProviderConfigJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_JobSearchSessions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "JobSearchTokens",
schema: "cvSearch",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
Used = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_JobSearchTokens", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_JobSearchResults_SessionId",
schema: "cvSearch",
table: "JobSearchResults",
column: "SessionId");
migrationBuilder.CreateIndex(
name: "IX_JobSearchSessions_Status",
schema: "cvSearch",
table: "JobSearchSessions",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JobSearchResults",
schema: "cvSearch");
migrationBuilder.DropTable(
name: "JobSearchSessions",
schema: "cvSearch");
migrationBuilder.DropTable(
name: "JobSearchTokens",
schema: "cvSearch");
}
}
}
@@ -1,46 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Models.Migrations
{
/// <inheritdoc />
public partial class AddLanguageToJobSearchEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Language",
schema: "cvSearch",
table: "JobSearchTokens",
type: "nvarchar(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
migrationBuilder.AddColumn<string>(
name: "Language",
schema: "cvSearch",
table: "JobSearchSessions",
type: "nvarchar(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Language",
schema: "cvSearch",
table: "JobSearchTokens");
migrationBuilder.DropColumn(
name: "Language",
schema: "cvSearch",
table: "JobSearchSessions");
}
}
}
@@ -1,171 +0,0 @@
// <auto-generated />
using System;
using CvSearch.Models.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Models.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
partial class CvSearchDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", 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>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", 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>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,21 +0,0 @@
namespace CvSearch.Models.Settings;
public sealed class JobSearchSettings
{
public bool Enabled { get; set; } = true;
public string JobSearchLinkBaseUrl { get; set; } = string.Empty;
public int TokenExpiryDays { get; set; } = 7;
public int MinMatchScore { get; set; } = 15;
public int MaxJobsToMatch { get; set; } = 15;
public List<JobProviderConfig> Providers { get; set; } = [];
}
public sealed class JobProviderConfig
{
public string Name { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public string SearchUrlTemplate { get; set; } = string.Empty;
public string JobLinkContains { get; set; } = string.Empty;
public List<string> InitialKeywords { get; set; } = [];
public int MaxResults { get; set; } = 20;
}
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>CvSearch.Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+1 -1
View File
@@ -15,7 +15,7 @@ public sealed class EmailApiDbContext : DbContext
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
base.OnConfiguring(optionsBuilder); base.OnConfiguring(optionsBuilder);
// Configure migration history table to use schema-qualified name: [emailApi].[_Migrations] // Configure migration history table to use schema-qualified name: [email].[_Migrations]
optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName)); optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName));
} }
@@ -185,7 +185,7 @@ namespace Email.Data.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi"); migrationBuilder.DropTable(name: "EmailTemplates", schema: MigrationConstants.SchemaName);
} }
} }
} }
-18
View File
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>MyAi.Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -1,62 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MyAi.Models.Data;
#nullable disable
namespace MyAi.Models.Migrations
{
[DbContext(typeof(MyAiDbContext))]
[Migration("20260524145351_AddTemplates")]
partial class AddTemplates
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("myAi")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MyAi.Models.Data.Entities.TemplateEntity", 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("Templates", "myAi");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,113 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MyAi.Models.Migrations
{
/// <inheritdoc />
public partial class AddTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "myAi");
migrationBuilder.CreateTable(
name: "Templates",
schema: "myAi",
columns: table => new
{
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_Templates", x => new { x.Key, x.Language });
});
Seed(migrationBuilder);
}
private static void Seed(MigrationBuilder m)
{
void Row(string key, string lang, string value, string description = "")
=> m.InsertData("Templates", ["Key", "Language", "Value", "Description"], [key, lang, value, description], "myAi");
// Match result email — subject
Row("email.match.subject", "en", "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", "Subject for the CV match result email");
Row("email.match.subject", "ro", "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", "Subiect email rezultat potrivire CV");
// Match result email — body
Row("email.match.body", "en",
"CV Matcher result\n\nCV Document ID: {{cvDocumentId}}\nJob: {{jobLabel}}\nJob URL: {{jobUrl}}\nScore: {{score}}%\n\nSummary:\n{{summary}}\n\nStrengths:\n{{strengths}}\n\nGaps:\n{{gaps}}\n\nRecommendations:\n{{recommendations}}",
"Body for the CV match result email");
Row("email.match.body", "ro",
"Rezultat potrivire CV\n\nID document CV: {{cvDocumentId}}\nJob: {{jobLabel}}\nURL job: {{jobUrl}}\nScor: {{score}}%\n\nRezumat:\n{{summary}}\n\nPuncte forte:\n{{strengths}}\n\nLipsuri:\n{{gaps}}\n\nRecomandări:\n{{recommendations}}",
"Corpul emailului pentru rezultatul potrivirii CV");
// Match result email — job search CTA footer
Row("email.match.job-search-footer", "en",
"\n\n---\nWant to find more jobs matching your CV?\nClick: {{jobSearchLink}}\n(link valid for {{expiryDays}} days)",
"Job search CTA appended to match result email");
Row("email.match.job-search-footer", "ro",
"\n\n---\nVrei sa gasesti mai multe joburi potrivite CV-ului tau?\nClick: {{jobSearchLink}}\n(link valabil {{expiryDays}} zile)",
"CTA cautare joburi adaugat la emailul de potrivire CV");
// Job search results email — subject
Row("email.search-results.subject", "en", "MyAi.ro: {{count}} jobs matching your CV", "Subject for job search results email");
Row("email.search-results.subject", "ro", "MyAi.ro: {{count}} joburi potrivite CV-ului tau", "Subiect email rezultate cautare joburi");
// Job search results email — body preamble (items appended in code)
Row("email.search-results.body", "en", "MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}", "Body preamble for job search results email");
Row("email.search-results.body", "ro", "MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}", "Corpul emailului de rezultate cautare joburi");
// Job search results email — no results found
Row("email.search-results.empty", "en", "MyAi.ro found no jobs matching your CV. Try again later or update your CV.", "No results message for job search results email");
Row("email.search-results.empty", "ro", "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.", "Mesaj fara rezultate pentru emailul de cautare joburi");
// HTML job-search start page messages
Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page");
Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page");
Row("html.job-search.started.title", "ro", "Căutare joburi pornită", "Titlu pagina cautare joburi pornita");
Row("html.job-search.started.message", "ro", "Căutarea joburilor a început. Rezultatele vor fi trimise pe email în scurt timp.", "Mesaj pagina cautare joburi pornita");
Row("html.job-search.already-used.title", "en", "Link already used", "Title for already-used page");
Row("html.job-search.already-used.message", "en", "This job search link has already been used.", "Message for already-used page");
Row("html.job-search.already-used.title", "ro", "Link deja folosit", "Titlu pagina link deja folosit");
Row("html.job-search.already-used.message", "ro", "Acest link de cautare joburi a fost deja folosit.", "Mesaj pagina link deja folosit");
Row("html.job-search.expired.title", "en", "Link expired", "Title for expired link page");
Row("html.job-search.expired.message", "en", "This job search link has expired. Please request a new CV match to get a fresh link.", "Message for expired link page");
Row("html.job-search.expired.title", "ro", "Link expirat", "Titlu pagina link expirat");
Row("html.job-search.expired.message", "ro", "Acest link de cautare joburi a expirat. Solicita o noua potrivire CV pentru a primi un link nou.", "Mesaj pagina link expirat");
Row("html.job-search.invalid.title", "en", "Invalid link", "Title for invalid link page");
Row("html.job-search.invalid.message", "en", "This job search link is not valid.", "Message for invalid link page");
Row("html.job-search.invalid.title", "ro", "Link invalid", "Titlu pagina link invalid");
Row("html.job-search.invalid.message", "ro", "Acest link de cautare joburi nu este valid.", "Mesaj pagina link invalid");
Row("html.job-search.error.title", "en", "Error", "Title for error page");
Row("html.job-search.error.message", "en", "An error occurred. Please try again later.", "Message for error page");
Row("html.job-search.error.title", "ro", "Eroare", "Titlu pagina eroare");
Row("html.job-search.error.message", "ro", "A apărut o eroare. Te rugăm să încerci din nou mai târziu.", "Mesaj pagina eroare");
// AI system prompt for CV matching (language is a {{languageName}} variable inside it)
Row("ai.cv-match.system-prompt", "*",
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\nPenalize missing required skills. Do not invent experience. Use concise business language.\nRespond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\nJSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"]}",
"System prompt template for the CV-to-job LLM matching call. {{languageName}} is substituted at runtime.");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Templates",
schema: "myAi");
}
}
}
@@ -1,59 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MyAi.Models.Data;
#nullable disable
namespace MyAi.Models.Migrations
{
[DbContext(typeof(MyAiDbContext))]
partial class MyAiDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("myAi")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MyAi.Models.Data.Entities.TemplateEntity", 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("Templates", "myAi");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,70 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using MyAi.Models.Data;
using System.Collections.Concurrent;
namespace MyAi.Models.Services;
public sealed class DbTemplateService : ITemplateService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<DbTemplateService> _logger;
private ConcurrentDictionary<string, string> _cache = new(StringComparer.OrdinalIgnoreCase);
private DateTime _loadedAt = DateTime.MinValue;
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10);
public DbTemplateService(IServiceScopeFactory scopeFactory, ILogger<DbTemplateService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
public string Get(string key, string language = "en")
{
EnsureCacheLoaded();
if (_cache.TryGetValue(CacheKey(key, language), out var value))
return value;
if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase)
&& _cache.TryGetValue(CacheKey(key, "en"), out var fallback))
return fallback;
_logger.LogWarning("Template not found: key={Key}, language={Language}", key, language);
return key;
}
public string Render(string key, string language, params (string Key, string Value)[] placeholders)
{
var template = Get(key, language);
foreach (var (k, v) in placeholders)
template = template.Replace($"{{{{{k}}}}}", v, StringComparison.OrdinalIgnoreCase);
return template;
}
private void EnsureCacheLoaded()
{
if (DateTime.UtcNow - _loadedAt < CacheTtl) return;
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
var rows = db.Templates.AsNoTracking().ToList();
var fresh = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
fresh[CacheKey(row.Key, row.Language)] = row.Value;
_cache = fresh;
_loadedAt = DateTime.UtcNow;
_logger.LogDebug("Template cache refreshed. {Count} templates loaded.", rows.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh template cache. Serving stale cache.");
}
}
private static string CacheKey(string key, string language) => $"{key}::{language}";
}
@@ -1,7 +0,0 @@
namespace MyAi.Models.Services;
public interface ITemplateService
{
string Get(string key, string language = "en");
string Render(string key, string language, params (string Key, string Value)[] placeholders);
}
-18
View File
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>MyAi.Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -84,7 +84,7 @@ namespace Rag.Data.Migrations
table.ForeignKey( table.ForeignKey(
name: "FK_Chunks_Documents_DocumentId", name: "FK_Chunks_Documents_DocumentId",
column: x => x.DocumentId, column: x => x.DocumentId,
principalSchema: "rag", principalSchema: MigrationConstants.SchemaName,
principalTable: "Documents", principalTable: "Documents",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
@@ -124,7 +124,7 @@ public sealed class CvSearchEmailSender
{ {
var keywordsHtml = keywords.Count > 0 var keywordsHtml = keywords.Count > 0
? string.Join(" ", keywords.Select(k => ? string.Join(" ", keywords.Select(k =>
$"<span style=\"display:inline-block;background:#e9ecef;border-radius:4px;padding:2px 8px;margin:2px;font-size:12px\">{k}</span>")) $"<span style=\"display:inline-block;background:#e9ecef;border-radius:4px;padding:2px 8px;margin:2px 2px 2px 0;font-size:12px\">{k}</span>"))
: "<span style=\"color:#6c757d;font-size:12px;font-style:italic\">none detected</span>"; : "<span style=\"color:#6c757d;font-size:12px;font-style:italic\">none detected</span>";
var providersText = providerNames.Count > 0 var providersText = providerNames.Count > 0