diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 6e5af67..03098f2 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -181,10 +181,13 @@ 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); - 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/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/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/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-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/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 195710b..4f8ba25 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -12,9 +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 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, 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 421bccf..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; @@ -13,35 +11,45 @@ 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. +/// 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) + { + _logger.LogDebug("Job search token skipped — no enabled providers in cvSearch.JobProviders"); + return null; + } + var token = new JobSearchTokenEntity { Id = Guid.NewGuid().ToString("N"), CvDocumentId = cvDocumentId, Email = email, Language = language, + Keywords = string.Join(",", keywords), ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), Used = false, CreatedAt = DateTime.UtcNow @@ -49,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; } @@ -64,11 +72,15 @@ 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) + .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,34 +98,35 @@ 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; } - /// - /// 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. - /// - private static string ExtractKeywords(string cvText) + private static JobProviderConfig ToConfig(JobProviderEntity entity) { - 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(); + List keywords; + try + { + keywords = JsonSerializer.Deserialize>(entity.InitialKeywordsJson, + new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? []; + } + catch + { + keywords = []; + } - var words = lines - .SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - .Select(w => Regex.Replace(w, @"[^\w\-]", "")) - .Where(w => w.Length > 2) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(10) - .ToList(); - - return string.Join(",", words); + return new JobProviderConfig + { + Name = entity.Name, + Enabled = entity.Enabled, + SearchUrlTemplate = entity.SearchUrlTemplate, + JobLinkContains = entity.JobLinkContains, + InitialKeywords = keywords, + MaxResults = entity.MaxResults + }; } + } 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/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 00837ee..6bc1133 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) { @@ -33,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()"); }); @@ -65,5 +67,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/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/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/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs similarity index 72% rename from Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs rename to Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs index 68602de..a079dc6 100644 --- a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs +++ b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs @@ -1,6 +1,6 @@ // using System; -using CvSearch.Models.Data; +using CvSearch.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace CvSearch.Models.Migrations +namespace CvSearch.Data.Migrations { [DbContext(typeof(CvSearchDbContext))] - [Migration("20260524145702_AddLanguageToJobSearchEntities")] - partial class AddLanguageToJobSearchEntities + [Migration("20260529084440_AddJobProviders")] + partial class AddJobProviders { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b => + 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) @@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations b.ToTable("JobSearchResults", "cvSearch"); }); - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -128,7 +176,7 @@ namespace CvSearch.Models.Migrations b.ToTable("JobSearchSessions", "cvSearch"); }); - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => { b.Property("Id") .HasMaxLength(64) 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..795417e --- /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: MigrationConstants.SchemaName, + 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: MigrationConstants.SchemaName, + 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: MigrationConstants.SchemaName); + } + } +} diff --git a/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs similarity index 67% rename from Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs rename to Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs index 5fd3d9e..d616025 100644 --- a/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs @@ -1,19 +1,22 @@ -// +// using System; -using CvSearch.Models.Data; +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.Models.Migrations +namespace CvSearch.Data.Migrations { [DbContext(typeof(CvSearchDbContext))] - partial class CvSearchDbContextModelSnapshot : ModelSnapshot + [Migration("20260529130000_AddKeywordsToJobSearchTokens")] + partial class AddKeywordsToJobSearchTokens { - protected override void BuildModel(ModelBuilder modelBuilder) + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder @@ -23,7 +26,55 @@ namespace CvSearch.Models.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b => + 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) @@ -72,7 +123,7 @@ namespace CvSearch.Models.Migrations b.ToTable("JobSearchResults", "cvSearch"); }); - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -125,7 +176,7 @@ namespace CvSearch.Models.Migrations b.ToTable("JobSearchSessions", "cvSearch"); }); - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -149,6 +200,13 @@ namespace CvSearch.Models.Migrations b.Property("ExpiresAt") .HasColumnType("datetime2"); + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + b.Property("Language") .IsRequired() .ValueGeneratedOnAdd() 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 1cb9f20..9005fb5 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") @@ -149,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() 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.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/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/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); } } } 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 - - - - 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); diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index fd95104..3262d8e 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..db92305 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 { @@ -178,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 []; } } 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": [ 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 @@ - +