From af3a14c7ed165f7751a9e878fa1073c205dd9e35 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 11:00:04 +0300 Subject: [PATCH 01/11] feat(cv-search-job): enrich diagnostics and add scan summary to results email Add funnel-level logging to HtmlJobSearcher (total anchors found, stage-1 href-filter count, stage-2 keyword-filter count) and warn when the keyword list is empty. Log the full search URL and response size to catch silent HTTP failures or bot-block pages. In CvSearchJobTask, log keywords and active providers at session start, per-provider URL counts after each scrape, and every scored URL with its verdict (ACCEPTED / rejected) at Information level. Add a scan summary block to the results email (both non-empty and empty-results paths) showing the CV keywords used as chips and the comma-separated list of providers scanned. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/CvSearchEmailSender.cs | 37 ++++++++++++-- .../cv-search-job/Services/HtmlJobSearcher.cs | 30 +++++++++-- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 50 ++++++++++++++----- Jobs/cv-search-job/cv-search-job.csproj | 2 +- 4 files changed, 99 insertions(+), 20 deletions(-) diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index fd95104..48999c5 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -35,12 +35,16 @@ public sealed class CvSearchEmailSender /// Primary recipient (the user who triggered the search). /// Relative filename of the CV PDF to attach, or null. /// Ranked list of job search results to include in the email body. + /// CV keywords used to drive the job search. + /// Names of the providers that were scanned. /// Two-letter language code for template rendering. /// Cancellation token. public async Task SendResultsAsync( string toEmail, string? attachmentFileName, IReadOnlyList results, + IReadOnlyList keywords, + IReadOnlyList providerNames, string language, CancellationToken ct) { @@ -54,7 +58,7 @@ public sealed class CvSearchEmailSender if (recipients.Count == 0) return; - var htmlBody = BuildBody(results, language); + var htmlBody = BuildBody(results, keywords, providerNames, language); var subject = _emailTemplates.Render("email.search-results.subject", language, ("count", results.Count.ToString())); @@ -81,11 +85,14 @@ public sealed class CvSearchEmailSender /// /// Renders the HTML email body from the results list. /// Returns the empty-results template when no results are present. + /// Prepends a scan summary block showing the keywords and providers used. /// - private string BuildBody(IReadOnlyList results, string language) + private string BuildBody(IReadOnlyList results, IReadOnlyList keywords, IReadOnlyList providerNames, string language) { + var scanSummary = BuildScanSummary(keywords, providerNames); + if (results.Count == 0) - return _emailTemplates.Get("email.search-results.empty", language); + return scanSummary + _emailTemplates.Get("email.search-results.empty", language); var items = new System.Text.StringBuilder(); for (int i = 0; i < results.Count; i++) @@ -107,7 +114,29 @@ public sealed class CvSearchEmailSender return _emailTemplates.Render("email.search-results.body", language, ("count", results.Count.ToString()), - ("items", items.ToString())); + ("items", scanSummary + items.ToString())); + } + + /// + /// Builds the scan summary block showing the CV keywords and providers used for the search. + /// + private static string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames) + { + var keywordsHtml = keywords.Count > 0 + ? string.Join("", keywords.Select(k => + $"{k}")) + : "none detected"; + + var providersText = providerNames.Count > 0 + ? string.Join(", ", providerNames) + : "none"; + + return $""" +
+
Keywords used: {keywordsHtml}
+
Providers scanned: {providersText}
+
+ """; } /// diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index d3dcd5d..c99fc62 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -44,19 +44,27 @@ public sealed class HtmlJobSearcher .ToList(); if (allKeywords.Count == 0) + { + _logger.LogWarning("Provider {Provider}: no keywords available (CV keywords empty, InitialKeywords empty), skipping", provider.Name); return []; + } var keywordsEncoded = HttpUtility.UrlEncode(string.Join(" ", allKeywords)); var searchUrl = provider.SearchUrlTemplate.Replace("{keywords}", keywordsEncoded); + _logger.LogInformation( + "Provider {Provider}: fetching {Url} | CV keywords: [{Keywords}]", + provider.Name, searchUrl, string.Join(", ", cvKeywords)); + string html; try { html = await _http.GetStringAsync(searchUrl, ct); + _logger.LogInformation("Provider {Provider}: received {Length} chars of HTML", provider.Name, html.Length); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to fetch search results from {Provider} at {Url}", provider.Name, searchUrl); + _logger.LogWarning(ex, "Provider {Provider}: HTTP fetch failed for {Url}", provider.Name, searchUrl); return []; } @@ -68,7 +76,11 @@ public sealed class HtmlJobSearcher var anchorPattern = new Regex(@"]+href=[""']([^""']+)[""'][^>]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline); - foreach (Match match in anchorPattern.Matches(html)) + var allAnchors = anchorPattern.Matches(html); + var stage1Pass = 0; + var stage2Pass = 0; + + foreach (Match match in allAnchors) { if (results.Count >= provider.MaxResults) break; @@ -78,9 +90,18 @@ public sealed class HtmlJobSearcher if (!href.Contains(provider.JobLinkContains, StringComparison.OrdinalIgnoreCase)) continue; + stage1Pass++; + // Stage 2: anchor text must contain at least one CV keyword if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogDebug( + "Provider {Provider}: stage-2 reject | href={Href} | text={Text}", + provider.Name, href, anchorText.Length > 100 ? anchorText[..100] : anchorText); continue; + } + + stage2Pass++; // Make absolute URL if (!Uri.TryCreate(href, UriKind.Absolute, out var absoluteUri)) @@ -95,7 +116,10 @@ public sealed class HtmlJobSearcher results.Add(url); } - _logger.LogInformation("Provider {Provider}: found {Count} job URLs", provider.Name, results.Count); + _logger.LogInformation( + "Provider {Provider}: {TotalAnchors} anchors found | {Stage1} passed href filter ('{LinkPattern}') | {Stage2} passed keyword filter | {Unique} unique URLs returned", + provider.Name, allAnchors.Count, stage1Pass, provider.JobLinkContains, stage2Pass, results.Count); + return results; } } diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 76791be..eb1ce80 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -84,13 +84,35 @@ public sealed class CvSearchJobTask : IJobTask try { - var results = await RunSearchAsync(pending, db, cancellationToken); + var cvKeywords = pending.Keywords + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(k => k.Trim()) + .Where(k => k.Length > 0) + .ToList(); + + var providers = GetProviders(pending.ProviderConfigJson); + + _logger.LogInformation( + "Session {SessionId}: keywords=[{Keywords}] | providers=[{Providers}]", + pending.Id, + cvKeywords.Count > 0 ? string.Join(", ", cvKeywords) : "(none)", + providers.Count > 0 ? string.Join(", ", providers.Select(p => p.Name)) : "(none)"); + + var results = await RunSearchAsync(pending, cvKeywords, providers, db, cancellationToken); pending.Status = JobSearchStatus.Done; await db.SaveChangesAsync(cancellationToken); var attachmentFileName = BuildCvFileName(pending.CvDocumentId); - await _emailSender.SendResultsAsync(pending.Email, attachmentFileName, results, pending.Language, cancellationToken); + await _emailSender.SendResultsAsync( + pending.Email, + attachmentFileName, + results, + cvKeywords, + providers.Select(p => p.Name).ToList(), + pending.Language, + cancellationToken); + _logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count); } catch (Exception ex) @@ -107,26 +129,27 @@ public sealed class CvSearchJobTask : IJobTask /// private async Task> RunSearchAsync( JobSearchSessionEntity session, + List cvKeywords, + List providers, CvSearchDbContext db, CancellationToken ct) { - var cvKeywords = session.Keywords - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(k => k.Trim()) - .Where(k => k.Length > 0) - .ToList(); + if (cvKeywords.Count == 0) + _logger.LogWarning("Session {SessionId}: keyword list is empty — scraper will rely on provider InitialKeywords only", session.Id); - var providers = GetProviders(session.ProviderConfigJson); var jobUrls = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var provider in providers) { var urls = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, ct); + _logger.LogInformation("Session {SessionId}: provider {Provider} returned {Count} URLs", session.Id, provider.Name, urls.Count); foreach (var url in urls) jobUrls.Add(url); } var candidates = jobUrls.Take(_settings.MaxJobsToMatch).ToList(); - _logger.LogInformation("Session {SessionId}: {Count} candidate job URLs to match", session.Id, candidates.Count); + _logger.LogInformation( + "Session {SessionId}: {Total} unique URLs across all providers, scoring {Scoring} (cap={Cap})", + session.Id, jobUrls.Count, candidates.Count, _settings.MaxJobsToMatch); var results = new List(); @@ -143,11 +166,14 @@ public sealed class CvSearchJobTask : IJobTask }; var matchResult = await _matcherApi.MatchJobAsync(matchRequest, ct); + + _logger.LogInformation( + "Session {SessionId}: {Url} → score={Score}% (threshold={Threshold}%) {Verdict}", + session.Id, url, matchResult.Score, _settings.MinMatchScore, + matchResult.Score >= _settings.MinMatchScore ? "ACCEPTED" : "rejected"); + if (matchResult.Score < _settings.MinMatchScore) - { - _logger.LogDebug("Session {SessionId}: {Url} scored {Score}% (below threshold)", session.Id, url, matchResult.Score); continue; - } var entity = new JobSearchResultEntity { diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index 982a4a6..120418e 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -21,10 +21,10 @@ - + -- 2.52.0 From 7c09f5a871f15c382ebf42b3900f4b3e868b74d2 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 11:46:34 +0300 Subject: [PATCH 02/11] feat(cv-search-data): add JobProviders table to cvSearch schema New JobProviderEntity persists provider config (name, URL template, link filter, initial keywords, max results, display order) in the DB instead of appsettings. Migration seeds three disabled defaults: ejobs.ro, bestjobs.eu, and linkedin.com. Closes #35 Co-Authored-By: Claude Sonnet 4.6 --- Apis/cv-search-data/Data/CvSearchDbContext.cs | 14 ++ .../Data/Entities/JobProviderEntity.cs | 33 +++ ...20260529084440_AddJobProviders.Designer.cs | 222 ++++++++++++++++++ .../20260529084440_AddJobProviders.cs | 55 +++++ .../CvSearchDbContextModelSnapshot.cs | 50 +++- 5 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 Apis/cv-search-data/Data/Entities/JobProviderEntity.cs create mode 100644 Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs index 00837ee..891243d 100644 --- a/Apis/cv-search-data/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -13,6 +13,7 @@ public sealed class CvSearchDbContext : DbContext public DbSet JobSearchTokens => Set(); public DbSet JobSearchSessions => Set(); public DbSet JobSearchResults => Set(); + public DbSet JobProviders => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -65,5 +66,18 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.HasIndex(x => x.SessionId); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("JobProviders"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).UseIdentityColumn(); + entity.Property(x => x.Name).HasMaxLength(128).IsRequired(); + entity.Property(x => x.SearchUrlTemplate).HasMaxLength(1024).IsRequired(); + entity.Property(x => x.JobLinkContains).HasMaxLength(256).IsRequired(); + entity.Property(x => x.InitialKeywordsJson).HasMaxLength(2000).HasDefaultValue("[]").IsRequired(); + entity.Property(x => x.MaxResults).HasDefaultValue(20); + entity.Property(x => x.DisplayOrder).HasDefaultValue(0); + }); } } diff --git a/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs new file mode 100644 index 0000000..79e76e2 --- /dev/null +++ b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs @@ -0,0 +1,33 @@ +namespace CvSearch.Data.Entities; + +/// +/// Persisted job-board provider configuration. Stored in cvSearch.JobProviders. +/// Providers are loaded from here at session-creation time and snapshotted into +/// JobSearchSessionEntity.ProviderConfigJson so runtime config changes do not +/// affect already-queued sessions. +/// +public sealed class JobProviderEntity +{ + public int Id { get; set; } + + /// Display name (e.g. "ejobs.ro"). + public string Name { get; set; } = string.Empty; + + /// When false the provider is skipped at session-creation and the job-search link is hidden. + public bool Enabled { get; set; } + + /// URL template with {keywords} placeholder (URL-encoded keywords are substituted at runtime). + public string SearchUrlTemplate { get; set; } = string.Empty; + + /// Substring that must appear in an anchor href to pass the stage-1 link filter. + public string JobLinkContains { get; set; } = string.Empty; + + /// JSON array of baseline keywords merged with CV keywords before building the search URL. + public string InitialKeywordsJson { get; set; } = "[]"; + + /// Maximum number of job URLs to collect from this provider per session. + public int MaxResults { get; set; } = 20; + + /// Controls display ordering in future admin UIs. + public int DisplayOrder { get; set; } +} diff --git a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs new file mode 100644 index 0000000..a079dc6 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs @@ -0,0 +1,222 @@ +// +using System; +using CvSearch.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.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260529084440_AddJobProviders")] + partial class AddJobProviders + { + /// + 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.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs new file mode 100644 index 0000000..40761cd --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddJobProviders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "JobProviders", + schema: "cvSearch", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + SearchUrlTemplate = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: false), + JobLinkContains = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + InitialKeywordsJson = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false, defaultValue: "[]"), + MaxResults = table.Column(type: "int", nullable: false, defaultValue: 20), + DisplayOrder = table.Column(type: "int", nullable: false, defaultValue: 0) + }, + constraints: table => + { + table.PrimaryKey("PK_JobProviders", x => x.Id); + }); + + // 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. + migrationBuilder.InsertData( + schema: "cvSearch", + table: "JobProviders", + columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"], + values: new object[,] + { + { "ejobs.ro", false, "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", "[]", 20, 0 }, + { "bestjobs.eu", false, "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}", "/ro/locuri-de-munca/", "[]", 20, 1 }, + { "linkedin.com", false, "https://www.linkedin.com/jobs/search/?keywords={keywords}", "/jobs/view/", "[]", 20, 2 }, + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobProviders", + schema: "cvSearch"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index 1cb9f20..2b7a10c 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using CvSearch.Data; using Microsoft.EntityFrameworkCore; @@ -23,6 +23,54 @@ namespace CvSearch.Data.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => { b.Property("Id") -- 2.52.0 From d0d45bd2d346890e76d907d8ed10e9268e3a322d Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 11:46:44 +0300 Subject: [PATCH 03/11] feat(job-search): read providers from DB and suppress link when none enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JobTokenService.CreateTokenAsync queries cvSearch.JobProviders for any enabled row; returns null (no token created) when the table is empty or all providers are disabled. TriggerStartAsync snapshots enabled providers from DB at session-start time, preserving the existing snapshot contract. CvMatcherController guards link-building on a non-null TokenId so the "Start a job search" CTA is omitted from match emails when no providers are configured. JobSearchSettings.Providers list removed — provider config now lives exclusively in the DB. CvSearchJobTask.GetProviders falls back to an empty list with a warning (snapshot should always be populated from DB). Closes #35 Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CvMatcherController.cs | 7 ++- .../Responses/CreateJobSearchTokenResponse.cs | 6 ++- .../Settings/JobSearchSettings.cs | 5 +- .../Services/Contracts/IJobTokenService.cs | 7 ++- .../Services/JobTokenService.cs | 51 +++++++++++++++++-- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 17 +++++-- 6 files changed, 79 insertions(+), 14 deletions(-) diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 6e5af67..1111b78 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -183,8 +183,11 @@ public sealed class CvMatcherController : ControllerBase var tokenResp = await _jobSearchApi.CreateTokenAsync( new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language }, ct); - var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/'); - jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}"; + if (!string.IsNullOrWhiteSpace(tokenResp.TokenId)) + { + var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/'); + jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}"; + } } catch (Exception ex) { diff --git a/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs index 489d0ef..624eb8a 100644 --- a/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs +++ b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs @@ -2,5 +2,9 @@ namespace CvMatcher.Models.Responses; public sealed class CreateJobSearchTokenResponse { - public string TokenId { get; set; } = string.Empty; + /// + /// The generated token ID, or null when no job providers are currently enabled. + /// Callers must check for null before building the job-search link. + /// + public string? TokenId { get; set; } } diff --git a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs index 5afaa9d..e81b3a9 100644 --- a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs +++ b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs @@ -7,9 +7,12 @@ public sealed class JobSearchSettings public int TokenExpiryDays { get; set; } = 7; public int MinMatchScore { get; set; } = 15; public int MaxJobsToMatch { get; set; } = 15; - public List Providers { get; set; } = []; } +/// +/// Runtime DTO for a job provider. Populated from cvSearch.JobProviders at session-creation +/// time and snapshotted to JobSearchSessionEntity.ProviderConfigJson. +/// public sealed class JobProviderConfig { public string Name { get; set; } = string.Empty; diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index 195710b..8f04b35 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -13,8 +13,11 @@ public interface IJobTokenService /// Email address of the user who will receive the results. /// Preferred language for result emails (e.g. "en", "ro"). /// Cancellation token. - /// The generated token ID, to be embedded in the one-click job search link. - Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); + /// + /// The generated token ID to embed in the one-click job search link, + /// or null when no job providers are currently enabled (link should be suppressed). + /// + Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); /// /// Validates the token and, if valid, marks it as used and creates a Pending job search session. diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 421bccf..37a1e36 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -13,6 +13,9 @@ namespace Api.Services; /// /// Creates and validates one-time job search tokens, and creates the corresponding search sessions. +/// Provider configuration is read from cvSearch.JobProviders at session-creation time and +/// snapshotted into JobSearchSessionEntity.ProviderConfigJson so subsequent config changes +/// do not affect already-queued sessions. /// public sealed class JobTokenService : IJobTokenService { @@ -34,8 +37,15 @@ public sealed class JobTokenService : IJobTokenService } /// - public async Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) + public async Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) { + var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct); + if (!hasEnabledProviders) + { + _logger.LogDebug("Job search token skipped — no enabled providers in cvSearch.JobProviders"); + return null; + } + var token = new JobSearchTokenEntity { Id = Guid.NewGuid().ToString("N"), @@ -67,8 +77,13 @@ public sealed class JobTokenService : IJobTokenService var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct); var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty; + var enabledProviders = await _db.JobProviders + .Where(p => p.Enabled) + .OrderBy(p => p.DisplayOrder) + .ToListAsync(ct); + var providerConfigJson = JsonSerializer.Serialize( - _settings.Providers.Where(p => p.Enabled).ToList(), + enabledProviders.Select(ToConfig).ToList(), new JsonSerializerOptions(JsonSerializerDefaults.Web)); var session = new JobSearchSessionEntity @@ -86,11 +101,41 @@ public sealed class JobTokenService : IJobTokenService _db.JobSearchSessions.Add(session); await _db.SaveChangesAsync(ct); - _logger.LogInformation("Job search session created. SessionId={SessionId}, Keywords={Keywords}", session.Id, keywords); + _logger.LogInformation( + "Job search session created. SessionId={SessionId}, Keywords={Keywords}, Providers={Providers}", + session.Id, keywords, string.Join(", ", enabledProviders.Select(p => p.Name))); return StartJobSearchStatus.Started; } + /// + /// Maps a to the DTO used by + /// cv-search-job. The InitialKeywords list is stored as a JSON array in the entity. + /// + private static JobProviderConfig ToConfig(JobProviderEntity entity) + { + List keywords; + try + { + keywords = JsonSerializer.Deserialize>(entity.InitialKeywordsJson, + new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? []; + } + catch + { + keywords = []; + } + + return new JobProviderConfig + { + Name = entity.Name, + Enabled = entity.Enabled, + SearchUrlTemplate = entity.SearchUrlTemplate, + JobLinkContains = entity.JobLinkContains, + InitialKeywords = keywords, + MaxResults = entity.MaxResults + }; + } + /// /// 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. diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index eb1ce80..db92305 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -204,20 +204,27 @@ public sealed class CvSearchJobTask : IJobTask /// /// Deserialises the provider configuration snapshot stored on the session. - /// Falls back to the current live config when the snapshot is absent or unparseable. + /// Providers are always snapshotted from the DB at session-creation time, so the snapshot + /// should always be present. Returns an empty list (with a warning) when it is missing or corrupt. /// private List GetProviders(string? providerConfigJson) { - if (string.IsNullOrWhiteSpace(providerConfigJson)) return _settings.Providers.Where(p => p.Enabled).ToList(); + if (string.IsNullOrWhiteSpace(providerConfigJson)) + { + _logger.LogWarning("Session has no provider config snapshot — returning empty provider list"); + return []; + } + try { return JsonSerializer.Deserialize>(providerConfigJson, new JsonSerializerOptions(JsonSerializerDefaults.Web)) - ?? _settings.Providers.Where(p => p.Enabled).ToList(); + ?? []; } - catch + catch (Exception ex) { - return _settings.Providers.Where(p => p.Enabled).ToList(); + _logger.LogWarning(ex, "Failed to deserialise provider config snapshot — returning empty provider list"); + return []; } } -- 2.52.0 From c8d1a21736075cf283e9d83f4c32f7a9e95d3583 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 11:53:17 +0300 Subject: [PATCH 04/11] =?UTF-8?q?chore(config):=20remove=20Providers=20arr?= =?UTF-8?q?ay=20from=20appsettings=20=E2=80=94=20now=20in=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider config is no longer read from appsettings or env vars. All three providers (ejobs.ro, bestjobs.eu, linkedin.com) are seeded into cvSearch.JobProviders by the AddJobProviders migration. Co-Authored-By: Claude Sonnet 4.6 --- Apis/cv-matcher-api/appsettings.json | 28 +--------------------------- Jobs/cv-search-job/appsettings.json | 28 +--------------------------- 2 files changed, 2 insertions(+), 54 deletions(-) diff --git a/Apis/cv-matcher-api/appsettings.json b/Apis/cv-matcher-api/appsettings.json index 9fe037c..b3a6aa6 100644 --- a/Apis/cv-matcher-api/appsettings.json +++ b/Apis/cv-matcher-api/appsettings.json @@ -112,32 +112,6 @@ "JobSearchLinkBaseUrl": "https://myai.ro", "TokenExpiryDays": 7, "MinMatchScore": 15, - "MaxJobsToMatch": 15, - "Providers": [ - { - "Name": "ejobs.ro", - "Enabled": false, - "SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/", - "JobLinkContains": "/user/locuri-de-munca/job/", - "InitialKeywords": [], - "MaxResults": 20 - }, - { - "Name": "bestjobs.eu", - "Enabled": false, - "SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}", - "JobLinkContains": "/ro/locuri-de-munca/", - "InitialKeywords": [], - "MaxResults": 20 - }, - { - "Name": "linkedin.com", - "Enabled": false, - "SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania", - "JobLinkContains": "/jobs/view/", - "InitialKeywords": [], - "MaxResults": 20 - } - ] + "MaxJobsToMatch": 15 } } diff --git a/Jobs/cv-search-job/appsettings.json b/Jobs/cv-search-job/appsettings.json index 1cd79ec..e7e9343 100644 --- a/Jobs/cv-search-job/appsettings.json +++ b/Jobs/cv-search-job/appsettings.json @@ -103,33 +103,7 @@ "JobSearchLinkBaseUrl": "https://myai.ro", "TokenExpiryDays": 7, "MinMatchScore": 15, - "MaxJobsToMatch": 15, - "Providers": [ - { - "Name": "ejobs.ro", - "Enabled": false, - "SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/", - "JobLinkContains": "/user/locuri-de-munca/job/", - "InitialKeywords": [], - "MaxResults": 20 - }, - { - "Name": "bestjobs.eu", - "Enabled": false, - "SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}", - "JobLinkContains": "/ro/locuri-de-munca/", - "InitialKeywords": [], - "MaxResults": 20 - }, - { - "Name": "linkedin.com", - "Enabled": false, - "SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania", - "JobLinkContains": "/jobs/view/", - "InitialKeywords": [], - "MaxResults": 20 - } - ] + "MaxJobsToMatch": 15 }, "Jobs": { "Tasks": [ -- 2.52.0 From c675954f8a66b468464eb9dfca2d7ef9c5a801ce Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:15:44 +0300 Subject: [PATCH 05/11] 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 --- .../Migrations/20260529084440_AddJobProviders.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs index 40761cd..795417e 100644 --- a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs +++ b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs @@ -12,7 +12,7 @@ namespace CvSearch.Data.Migrations { migrationBuilder.CreateTable( name: "JobProviders", - schema: "cvSearch", + schema: MigrationConstants.SchemaName, columns: table => new { Id = table.Column(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. // Enable a provider by setting its Enabled column to 1 via SQL or a future admin UI. migrationBuilder.InsertData( - schema: "cvSearch", + schema: MigrationConstants.SchemaName, table: "JobProviders", columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"], values: new object[,] @@ -49,7 +49,7 @@ namespace CvSearch.Data.Migrations { migrationBuilder.DropTable( name: "JobProviders", - schema: "cvSearch"); + schema: MigrationConstants.SchemaName); } } } -- 2.52.0 From 25731868ee17edbc4f7a8cbb71df6412b953889a Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:19:58 +0300 Subject: [PATCH 06/11] 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 --- Apis/email-data/EmailApiDbContext.cs | 2 +- .../Migrations/20260528100000_CreateEmailTemplates.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Apis/email-data/EmailApiDbContext.cs b/Apis/email-data/EmailApiDbContext.cs index 2c28594..61cb29d 100644 --- a/Apis/email-data/EmailApiDbContext.cs +++ b/Apis/email-data/EmailApiDbContext.cs @@ -15,7 +15,7 @@ public sealed class EmailApiDbContext : DbContext protected override void OnConfiguring(DbContextOptionsBuilder 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)); } diff --git a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs index ed96f21..797adf5 100644 --- a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs +++ b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs @@ -185,7 +185,7 @@ namespace Email.Data.Migrations /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi"); + migrationBuilder.DropTable(name: "EmailTemplates", schema: MigrationConstants.SchemaName); } } } -- 2.52.0 From a467fac35d56c5ec8be51ce038851037ef0725f4 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:29:10 +0300 Subject: [PATCH 07/11] 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 --- .../Services/JobTokenService.cs | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 37a1e36..b565071 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -138,23 +138,21 @@ public sealed class JobTokenService : IJobTokenService /// /// 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. + /// Samples the first 2000 characters (where title/role/skills usually appear), splits by + /// whitespace and common delimiters, strips punctuation, and deduplicates. + /// Works regardless of whether the PDF extractor preserves newlines. /// 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(); + // Focus on the header area where name/title/skills typically appear + var sample = cvText.Length > 2000 ? cvText[..2000] : cvText; - var words = lines - .SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - .Select(w => Regex.Replace(w, @"[^\w\-]", "")) + var words = sample + .Split([' ', '\n', '\r', '\t', '|', '/', ',', ';', '(', ')'], StringSplitOptions.RemoveEmptyEntries) + .Select(w => Regex.Replace(w, @"[^\w\-]", "").Trim('-')) .Where(w => w.Length > 2) + .Where(w => !Regex.IsMatch(w, @"^[\d\-]+$")) // skip phone fragments and pure numbers + .Where(w => !w.Contains('@') && !w.Contains('.')) // skip emails and URLs .Distinct(StringComparer.OrdinalIgnoreCase) .Take(10) .ToList(); -- 2.52.0 From b78ede23cfaa3ab27c34700671b6bcccdd2a9f5d Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:44:13 +0300 Subject: [PATCH 08/11] feat(job-search): extract keywords from LLM match call instead of heuristics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- Apis/api/Controllers/CvMatcherController.cs | 2 +- .../Requests/CreateJobSearchTokenRequest.cs | 1 + .../Responses/JobMatchResponse.cs | 1 + .../Controllers/JobSearchController.cs | 2 +- .../Services/Contracts/IJobTokenService.cs | 3 +- .../Services/JobTokenService.cs | 42 +--- ...ateCvMatchSystemPromptKeywords.Designer.cs | 130 ++++++++++ ...40000_UpdateCvMatchSystemPromptKeywords.cs | 49 ++++ Apis/cv-search-data/Data/CvSearchDbContext.cs | 1 + .../Data/Entities/JobSearchTokenEntity.cs | 1 + ...0_AddKeywordsToJobSearchTokens.Designer.cs | 229 ++++++++++++++++++ ...0529130000_AddKeywordsToJobSearchTokens.cs | 33 +++ .../CvSearchDbContextModelSnapshot.cs | 7 + 13 files changed, 462 insertions(+), 39 deletions(-) create mode 100644 Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs create mode 100644 Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs create mode 100644 Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.cs diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 1111b78..03098f2 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -181,7 +181,7 @@ public sealed class CvMatcherController : ControllerBase try { 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); if (!string.IsNullOrWhiteSpace(tokenResp.TokenId)) { diff --git a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs index a496b0c..6e1bcb4 100644 --- a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs +++ b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs @@ -5,4 +5,5 @@ public sealed class CreateJobSearchTokenRequest public string CvDocumentId { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; public string Language { get; set; } = "en"; + public List Keywords { get; set; } = []; } diff --git a/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs b/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs index 8ef3e1d..b7ffe7b 100644 --- a/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs +++ b/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs @@ -8,6 +8,7 @@ public List Gaps { get; set; } = []; public List Recommendations { get; set; } = []; public List Evidence { get; set; } = []; + public List Keywords { get; set; } = []; public bool Cached { get; set; } public string? JobDocumentId { get; set; } public string? JobUrl { get; set; } diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index 65bd1eb..c44e7a5 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email)) 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 }); } catch (Exception ex) diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index 8f04b35..4f8ba25 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -12,12 +12,13 @@ public interface IJobTokenService /// Identifier of the indexed CV document. /// Email address of the user who will receive the results. /// Preferred language for result emails (e.g. "en", "ro"). + /// Job search keywords extracted by the LLM during the match call. /// Cancellation token. /// /// The generated token ID to embed in the one-click job search link, /// or null when no job providers are currently enabled (link should be suppressed). /// - Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); + Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, CancellationToken ct); /// /// Validates the token and, if valid, marks it as used and creates a Pending job search session. diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index b565071..e2a07d8 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -1,6 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; -using Api.Clients.Api.Contracts; using Api.Services.Contracts; using CvMatcher.Models.Responses; using CvSearch.Data; @@ -16,28 +14,27 @@ namespace Api.Services; /// Provider configuration is read from cvSearch.JobProviders at session-creation time and /// snapshotted into JobSearchSessionEntity.ProviderConfigJson so subsequent config changes /// 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. /// public sealed class JobTokenService : IJobTokenService { private readonly CvSearchDbContext _db; - private readonly IRagApiClient _rag; private readonly JobSearchSettings _settings; private readonly ILogger _logger; public JobTokenService( CvSearchDbContext db, - IRagApiClient rag, IOptions settings, ILogger logger) { _db = db; - _rag = rag; _settings = settings.Value; _logger = logger; } /// - public async Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) + public async Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, CancellationToken ct) { var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct); if (!hasEnabledProviders) @@ -52,6 +49,7 @@ public sealed class JobTokenService : IJobTokenService CvDocumentId = cvDocumentId, Email = email, Language = language, + Keywords = string.Join(",", keywords), ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), Used = false, CreatedAt = DateTime.UtcNow @@ -59,7 +57,7 @@ public sealed class JobTokenService : IJobTokenService _db.JobSearchTokens.Add(token); 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; } @@ -74,8 +72,7 @@ public sealed class JobTokenService : IJobTokenService token.Used = true; await _db.SaveChangesAsync(ct); - var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct); - var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty; + var keywords = token.Keywords; var enabledProviders = await _db.JobProviders .Where(p => p.Enabled) @@ -108,10 +105,6 @@ public sealed class JobTokenService : IJobTokenService return StartJobSearchStatus.Started; } - /// - /// Maps a to the DTO used by - /// cv-search-job. The InitialKeywords list is stored as a JSON array in the entity. - /// private static JobProviderConfig ToConfig(JobProviderEntity entity) { List keywords; @@ -136,27 +129,4 @@ public sealed class JobTokenService : IJobTokenService }; } - /// - /// Extracts up to 10 meaningful keywords from the CV text using simple heuristics (no LLM). - /// Samples the first 2000 characters (where title/role/skills usually appear), splits by - /// whitespace and common delimiters, strips punctuation, and deduplicates. - /// Works regardless of whether the PDF extractor preserves newlines. - /// - private static string ExtractKeywords(string cvText) - { - // Focus on the header area where name/title/skills typically appear - var sample = cvText.Length > 2000 ? cvText[..2000] : cvText; - - var words = sample - .Split([' ', '\n', '\r', '\t', '|', '/', ',', ';', '(', ')'], StringSplitOptions.RemoveEmptyEntries) - .Select(w => Regex.Replace(w, @"[^\w\-]", "").Trim('-')) - .Where(w => w.Length > 2) - .Where(w => !Regex.IsMatch(w, @"^[\d\-]+$")) // skip phone fragments and pure numbers - .Where(w => !w.Contains('@') && !w.Contains('.')) // skip emails and URLs - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(10) - .ToList(); - - return string.Join(",", words); - } } diff --git a/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs b/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs new file mode 100644 index 0000000..6ed683b --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs @@ -0,0 +1,130 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId") + .IsUnique(); + + b.ToTable("Results", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => + { + b.Property("CacheKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("ResponseText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Temperature") + .HasColumnType("decimal(4,2)"); + + b.HasKey("CacheKey"); + + b.ToTable("ChatCache", "cvMatcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs b/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs new file mode 100644 index 0000000..5134906 --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using CvMatcher.Data; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + 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\"]}"; + + /// + 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); + } + + /// + 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); + } + } +} diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs index 891243d..6bc1133 100644 --- a/Apis/cv-search-data/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -34,6 +34,7 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.Email).HasMaxLength(256).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.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); }); diff --git a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs index e3d768c..68bd984 100644 --- a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs @@ -9,4 +9,5 @@ public sealed class JobSearchTokenEntity : BaseEntity public string Language { get; set; } = "en"; public DateTime ExpiresAt { get; set; } public bool Used { get; set; } + public string Keywords { get; set; } = string.Empty; } diff --git a/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs new file mode 100644 index 0000000..d616025 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs @@ -0,0 +1,229 @@ +// +using System; +using CvSearch.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.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260529130000_AddKeywordsToJobSearchTokens")] + partial class AddKeywordsToJobSearchTokens + { + /// + 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.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.cs b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.cs new file mode 100644 index 0000000..f346cd2 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using CvSearch.Data; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddKeywordsToJobSearchTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Keywords", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens", + type: "nvarchar(1000)", + maxLength: 1000, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Keywords", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index 2b7a10c..9005fb5 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -197,6 +197,13 @@ namespace CvSearch.Data.Migrations b.Property("ExpiresAt") .HasColumnType("datetime2"); + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + b.Property("Language") .IsRequired() .ValueGeneratedOnAdd() -- 2.52.0 From 9bedf57f39dbc6d11cec97d4a78a5d805dc500d0 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:46:41 +0300 Subject: [PATCH 09/11] 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 --- .../Migrations/20260522093356_AddJobSearchTables.cs | 4 ++-- Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs index b0b9e3e..689d5ef 100644 --- a/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs +++ b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs @@ -73,13 +73,13 @@ namespace CvSearch.Data.Migrations migrationBuilder.CreateIndex( name: "IX_JobSearchResults_SessionId", - schema: "cvSearch", + schema: MigrationConstants.SchemaName, table: "JobSearchResults", column: "SessionId"); migrationBuilder.CreateIndex( name: "IX_JobSearchSessions_Status", - schema: "cvSearch", + schema: MigrationConstants.SchemaName, table: "JobSearchSessions", column: "Status"); } diff --git a/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs index 05a38ac..2aa5b6b 100644 --- a/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs +++ b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs @@ -84,7 +84,7 @@ namespace Rag.Data.Migrations table.ForeignKey( name: "FK_Chunks_Documents_DocumentId", column: x => x.DocumentId, - principalSchema: "rag", + principalSchema: MigrationConstants.SchemaName, principalTable: "Documents", principalColumn: "Id", onDelete: ReferentialAction.Cascade); -- 2.52.0 From e5b6f19c1a88268be6bafdd441a89f80e94c747b Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:49:01 +0300 Subject: [PATCH 10/11] chore: remove orphaned project directories left over from renames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Apis/cv-search-data/cv-search-models.csproj | 18 -- ...60522093356_AddJobSearchTables.Designer.cs | 160 ---------------- .../20260522093356_AddJobSearchTables.cs | 102 ---------- ...AddLanguageToJobSearchEntities.Designer.cs | 174 ------------------ ...24145702_AddLanguageToJobSearchEntities.cs | 46 ----- .../CvSearchDbContextModelSnapshot.cs | 171 ----------------- .../Settings/JobSearchSettings.cs | 21 --- Apis/cv-search-models/cv-search-models.csproj | 18 -- Apis/myai-data/myai-models.csproj | 18 -- .../20260524145351_AddTemplates.Designer.cs | 62 ------- .../Migrations/20260524145351_AddTemplates.cs | 113 ------------ .../Migrations/MyAiDbContextModelSnapshot.cs | 59 ------ .../myai-models/Services/DbTemplateService.cs | 70 ------- Apis/myai-models/Services/ITemplateService.cs | 7 - Apis/myai-models/myai-models.csproj | 18 -- 15 files changed, 1057 deletions(-) delete mode 100644 Apis/cv-search-data/cv-search-models.csproj delete mode 100644 Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs delete mode 100644 Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs delete mode 100644 Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs delete mode 100644 Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs delete mode 100644 Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs delete mode 100644 Apis/cv-search-models/Settings/JobSearchSettings.cs delete mode 100644 Apis/cv-search-models/cv-search-models.csproj delete mode 100644 Apis/myai-data/myai-models.csproj delete mode 100644 Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs delete mode 100644 Apis/myai-models/Migrations/20260524145351_AddTemplates.cs delete mode 100644 Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs delete mode 100644 Apis/myai-models/Services/DbTemplateService.cs delete mode 100644 Apis/myai-models/Services/ITemplateService.cs delete mode 100644 Apis/myai-models/myai-models.csproj diff --git a/Apis/cv-search-data/cv-search-models.csproj b/Apis/cv-search-data/cv-search-models.csproj deleted file mode 100644 index 310b3cf..0000000 --- a/Apis/cv-search-data/cv-search-models.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - CvSearch.Models - enable - enable - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs deleted file mode 100644 index 475bd9b..0000000 --- a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs +++ /dev/null @@ -1,160 +0,0 @@ -// -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 - { - /// - 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("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("JobText") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("JobTitle") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("nvarchar(512)"); - - b.Property("JobUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("nvarchar(2048)"); - - b.Property("ProviderName") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("ResultJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Score") - .HasColumnType("int"); - - b.Property("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("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("Keywords") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("ProviderConfigJson") - .HasColumnType("nvarchar(max)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("nvarchar(32)"); - - b.Property("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("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("ExpiresAt") - .HasColumnType("datetime2"); - - b.Property("Used") - .ValueGeneratedOnAdd() - .HasColumnType("bit") - .HasDefaultValue(false); - - b.HasKey("Id"); - - b.ToTable("JobSearchTokens", "cvSearch"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs deleted file mode 100644 index adbc233..0000000 --- a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace CvSearch.Models.Migrations -{ - /// - public partial class AddJobSearchTables : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "cvSearch"); - - migrationBuilder.CreateTable( - name: "JobSearchResults", - schema: "cvSearch", - columns: table => new - { - Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - SessionId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - ProviderName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), - JobUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: false), - JobTitle = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), - JobText = table.Column(type: "nvarchar(max)", nullable: false), - Score = table.Column(type: "int", nullable: false), - ResultJson = table.Column(type: "nvarchar(max)", nullable: false), - CreatedAt = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), - TokenId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), - Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), - Keywords = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), - ProviderConfigJson = table.Column(type: "nvarchar(max)", nullable: true), - CreatedAt = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), - CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), - ExpiresAt = table.Column(type: "datetime2", nullable: false), - Used = table.Column(type: "bit", nullable: false, defaultValue: false), - CreatedAt = table.Column(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"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "JobSearchResults", - schema: "cvSearch"); - - migrationBuilder.DropTable( - name: "JobSearchSessions", - schema: "cvSearch"); - - migrationBuilder.DropTable( - name: "JobSearchTokens", - schema: "cvSearch"); - } - } -} diff --git a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs b/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs deleted file mode 100644 index 68602de..0000000 --- a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs +++ /dev/null @@ -1,174 +0,0 @@ -// -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("20260524145702_AddLanguageToJobSearchEntities")] - partial class AddLanguageToJobSearchEntities - { - /// - 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("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("JobText") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("JobTitle") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("nvarchar(512)"); - - b.Property("JobUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("nvarchar(2048)"); - - b.Property("ProviderName") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("ResultJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Score") - .HasColumnType("int"); - - b.Property("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("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("Keywords") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("Language") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(8) - .HasColumnType("nvarchar(8)") - .HasDefaultValue("en"); - - b.Property("ProviderConfigJson") - .HasColumnType("nvarchar(max)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("nvarchar(32)"); - - b.Property("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("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("ExpiresAt") - .HasColumnType("datetime2"); - - b.Property("Language") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(8) - .HasColumnType("nvarchar(8)") - .HasDefaultValue("en"); - - b.Property("Used") - .ValueGeneratedOnAdd() - .HasColumnType("bit") - .HasDefaultValue(false); - - b.HasKey("Id"); - - b.ToTable("JobSearchTokens", "cvSearch"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs b/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs deleted file mode 100644 index ac5ea0b..0000000 --- a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace CvSearch.Models.Migrations -{ - /// - public partial class AddLanguageToJobSearchEntities : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Language", - schema: "cvSearch", - table: "JobSearchTokens", - type: "nvarchar(8)", - maxLength: 8, - nullable: false, - defaultValue: "en"); - - migrationBuilder.AddColumn( - name: "Language", - schema: "cvSearch", - table: "JobSearchSessions", - type: "nvarchar(8)", - maxLength: 8, - nullable: false, - defaultValue: "en"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Language", - schema: "cvSearch", - table: "JobSearchTokens"); - - migrationBuilder.DropColumn( - name: "Language", - schema: "cvSearch", - table: "JobSearchSessions"); - } - } -} diff --git a/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs deleted file mode 100644 index 5fd3d9e..0000000 --- a/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs +++ /dev/null @@ -1,171 +0,0 @@ -// -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("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("JobText") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("JobTitle") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("nvarchar(512)"); - - b.Property("JobUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("nvarchar(2048)"); - - b.Property("ProviderName") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("ResultJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Score") - .HasColumnType("int"); - - b.Property("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("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("Keywords") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("Language") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(8) - .HasColumnType("nvarchar(8)") - .HasDefaultValue("en"); - - b.Property("ProviderConfigJson") - .HasColumnType("nvarchar(max)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("nvarchar(32)"); - - b.Property("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("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("ExpiresAt") - .HasColumnType("datetime2"); - - b.Property("Language") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(8) - .HasColumnType("nvarchar(8)") - .HasDefaultValue("en"); - - b.Property("Used") - .ValueGeneratedOnAdd() - .HasColumnType("bit") - .HasDefaultValue(false); - - b.HasKey("Id"); - - b.ToTable("JobSearchTokens", "cvSearch"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/cv-search-models/Settings/JobSearchSettings.cs b/Apis/cv-search-models/Settings/JobSearchSettings.cs deleted file mode 100644 index 2634509..0000000 --- a/Apis/cv-search-models/Settings/JobSearchSettings.cs +++ /dev/null @@ -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 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 InitialKeywords { get; set; } = []; - public int MaxResults { get; set; } = 20; -} diff --git a/Apis/cv-search-models/cv-search-models.csproj b/Apis/cv-search-models/cv-search-models.csproj deleted file mode 100644 index 310b3cf..0000000 --- a/Apis/cv-search-models/cv-search-models.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - CvSearch.Models - enable - enable - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/Apis/myai-data/myai-models.csproj b/Apis/myai-data/myai-models.csproj deleted file mode 100644 index cf8d4c5..0000000 --- a/Apis/myai-data/myai-models.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - MyAi.Models - enable - enable - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs b/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs deleted file mode 100644 index 1e7b3c9..0000000 --- a/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs +++ /dev/null @@ -1,62 +0,0 @@ -// -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 - { - /// - 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("Key") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("Language") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("Description") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Value") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Key", "Language"); - - b.ToTable("Templates", "myAi"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/myai-models/Migrations/20260524145351_AddTemplates.cs b/Apis/myai-models/Migrations/20260524145351_AddTemplates.cs deleted file mode 100644 index 299afc6..0000000 --- a/Apis/myai-models/Migrations/20260524145351_AddTemplates.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MyAi.Models.Migrations -{ - /// - public partial class AddTemplates : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "myAi"); - - migrationBuilder.CreateTable( - name: "Templates", - schema: "myAi", - columns: table => new - { - Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), - Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), - Value = table.Column(type: "nvarchar(max)", nullable: false), - Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), - UpdatedAt = table.Column(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."); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Templates", - schema: "myAi"); - } - } -} diff --git a/Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs b/Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs deleted file mode 100644 index d9a7549..0000000 --- a/Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs +++ /dev/null @@ -1,59 +0,0 @@ -// -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("Key") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("Language") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("Description") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Value") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Key", "Language"); - - b.ToTable("Templates", "myAi"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/myai-models/Services/DbTemplateService.cs b/Apis/myai-models/Services/DbTemplateService.cs deleted file mode 100644 index a19a48f..0000000 --- a/Apis/myai-models/Services/DbTemplateService.cs +++ /dev/null @@ -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 _logger; - private ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); - private DateTime _loadedAt = DateTime.MinValue; - private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); - - public DbTemplateService(IServiceScopeFactory scopeFactory, ILogger 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(); - var rows = db.Templates.AsNoTracking().ToList(); - var fresh = new ConcurrentDictionary(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}"; -} diff --git a/Apis/myai-models/Services/ITemplateService.cs b/Apis/myai-models/Services/ITemplateService.cs deleted file mode 100644 index 50eaad8..0000000 --- a/Apis/myai-models/Services/ITemplateService.cs +++ /dev/null @@ -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); -} diff --git a/Apis/myai-models/myai-models.csproj b/Apis/myai-models/myai-models.csproj deleted file mode 100644 index cf8d4c5..0000000 --- a/Apis/myai-models/myai-models.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - MyAi.Models - enable - enable - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - -- 2.52.0 From 9cf3db089d398b002f8f95040ca14a16e0f90df1 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 13:05:33 +0300 Subject: [PATCH 11/11] 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 --- Jobs/cv-search-job/Services/CvSearchEmailSender.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 48999c5..3262d8e 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -123,8 +123,8 @@ public sealed class CvSearchEmailSender private static string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames) { var keywordsHtml = keywords.Count > 0 - ? string.Join("", keywords.Select(k => - $"{k}")) + ? string.Join(" ", keywords.Select(k => + $"{k}")) : "none detected"; var providersText = providerNames.Count > 0 -- 2.52.0