From fc6fe7a78b365d3b755ec84ee2d85d9203136e9b Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 24 May 2026 18:06:44 +0300 Subject: [PATCH] feat: DB-backed localized templates + language-aware emails - New Apis/myai-models project: MyAiDbContext (schema myAi), TemplateEntity, ITemplateService, DbTemplateService with 10-min in-memory cache - Seeds EN+RO variants for all user-facing templates (match email, job search results email, HTML status pages, AI system prompt) - Match result email now sent in user's UI language (en/ro) - Job search results email now respects session language - Language propagates: MatchJobRequest -> token -> session -> email - Add Language column to JobSearchTokens and JobSearchSessions (default 'en') - All three Dockerfiles updated to include myai-models in build context Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CvMatcherController.cs | 26 ++- Apis/api/Dockerfile | 2 + Apis/api/Program.cs | 22 ++ Apis/api/Services/Contracts/IEmailSender.cs | 5 +- Apis/api/Services/SmtpEmailSender.cs | 67 +++--- Apis/api/api.csproj | 1 + .../Requests/CreateJobSearchTokenRequest.cs | 1 + .../Controllers/JobSearchController.cs | 2 +- Apis/cv-matcher-api/Dockerfile | 2 + Apis/cv-matcher-api/Program.cs | 18 ++ .../Services/Contracts/IJobTokenService.cs | 2 +- .../Services/CvMatcherService.cs | 14 +- .../Services/JobTokenService.cs | 4 +- Apis/cv-matcher-api/cv-matcher-api.csproj | 1 + .../Data/CvSearchDbContext.cs | 2 + .../Data/Entities/JobSearchSessionEntity.cs | 1 + .../Data/Entities/JobSearchTokenEntity.cs | 1 + ...AddLanguageToJobSearchEntities.Designer.cs | 174 ++++++++++++++++ ...24145702_AddLanguageToJobSearchEntities.cs | 46 +++++ .../CvSearchDbContextModelSnapshot.cs | 14 ++ .../Data/Entities/TemplateEntity.cs | 10 + Apis/myai-models/Data/MyAiDbContext.cs | 30 +++ .../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 ++ Jobs/cv-search-job/Dockerfile | 2 + Jobs/cv-search-job/Program.cs | 20 +- .../Services/CvSearchEmailSender.cs | 32 +-- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 2 +- Jobs/cv-search-job/cv-search-job.csproj | 1 + myAi.sln | 194 +++++++++++++++--- 34 files changed, 927 insertions(+), 98 deletions(-) create mode 100644 Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs create mode 100644 Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs create mode 100644 Apis/myai-models/Data/Entities/TemplateEntity.cs create mode 100644 Apis/myai-models/Data/MyAiDbContext.cs create mode 100644 Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs create mode 100644 Apis/myai-models/Migrations/20260524145351_AddTemplates.cs create mode 100644 Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs create mode 100644 Apis/myai-models/Services/DbTemplateService.cs create mode 100644 Apis/myai-models/Services/ITemplateService.cs create mode 100644 Apis/myai-models/myai-models.csproj diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 9946e98..76b07a9 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Swashbuckle.AspNetCore.Annotations; using Shared.Models.Responses; +using MyAi.Models.Services; namespace Api.Controllers; @@ -27,6 +28,7 @@ public sealed class CvMatcherController : ControllerBase private readonly FileStorageSettings _fileStorageSettings; private readonly JobSearchLinkSettings _jobSearchLinkSettings; private readonly IEmailSender _emailSender; + private readonly ITemplateService _templates; private readonly ILogger _logger; public CvMatcherController( @@ -36,6 +38,7 @@ public sealed class CvMatcherController : ControllerBase IOptions fileStorageSettings, IOptions jobSearchLinkSettings, IEmailSender emailSender, + ITemplateService templates, ILogger logger) { _cvApi = cvApi; @@ -44,6 +47,7 @@ public sealed class CvMatcherController : ControllerBase _fileStorageSettings = fileStorageSettings.Value; _jobSearchLinkSettings = jobSearchLinkSettings.Value; _emailSender = emailSender; + _templates = templates; _logger = logger; } @@ -160,13 +164,15 @@ public sealed class CvMatcherController : ControllerBase ? request.JobUrl : "Manual job description"; + var language = NormalizeLanguage(request.Language); + string? jobSearchLink = null; if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId)) { try { var tokenResp = await _jobSearchApi.CreateTokenAsync( - new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email }, + 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}"; @@ -179,8 +185,8 @@ public sealed class CvMatcherController : ControllerBase await _emailSender.SendMatchAsync( request.Email, - SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel), - SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, jobSearchLink), + _emailSender.BuildMatchEmailSubject(res.Score, jobLabel, language), + _emailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, language, jobSearchLink), attachmentPath, ct); @@ -217,23 +223,24 @@ public sealed class CvMatcherController : ControllerBase try { var result = await _jobSearchApi.StartSearchAsync(t, ct); + var lang = "en"; var html = result.Status switch { StartJobSearchStatus.Started => - HtmlPage("Job search started", "Your job search has started. Results will be sent to your email shortly."), + HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)), StartJobSearchStatus.AlreadyUsed => - HtmlPage("Link already used", "This job search link has already been used."), + HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)), StartJobSearchStatus.Expired => - HtmlPage("Link expired", "This job search link has expired. Please request a new CV match to get a fresh link."), + HtmlPage(_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)), _ => - HtmlPage("Invalid link", "This job search link is not valid.") + HtmlPage(_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang)) }; return Content(html, "text/html"); } catch (Exception ex) { _logger.LogError(ex, "Job search start failed for token {Token}.", t); - return Content(HtmlPage("Error", "An error occurred. Please try again later."), "text/html"); + return Content(HtmlPage(_templates.Get("html.job-search.error.title", "en"), _templates.Get("html.job-search.error.message", "en")), "text/html"); } } @@ -288,6 +295,9 @@ public sealed class CvMatcherController : ControllerBase return Path.Combine(GetFileStoragePath(), $"{safeId}.pdf"); } + private static string NormalizeLanguage(string? language) => + string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim(); + private string GetFileStoragePath() { var fileStoragePath = _fileStorageSettings.Path; diff --git a/Apis/api/Dockerfile b/Apis/api/Dockerfile index 3450b36..d344070 100644 --- a/Apis/api/Dockerfile +++ b/Apis/api/Dockerfile @@ -6,6 +6,7 @@ COPY Apis/api/api.csproj Apis/api/ COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ COPY Apis/api-models/api-models.csproj Apis/api-models/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ +COPY Apis/myai-models/myai-models.csproj Apis/myai-models/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ RUN dotnet restore Apis/api/api.csproj @@ -14,6 +15,7 @@ COPY Apis/api/ Apis/api/ COPY Apis/shared-models/ Apis/shared-models/ COPY Apis/api-models/ Apis/api-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ +COPY Apis/myai-models/ Apis/myai-models/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ RUN dotnet publish Apis/api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index c10ce85..4b92fbb 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -1,9 +1,13 @@ using System.Reflection; using Api.Services; using Api.Services.Contracts; +using Microsoft.EntityFrameworkCore; using Models.Settings; +using MyAi.Models.Data; +using MyAi.Models.Services; using Refit; using Serilog; +using Shared.Models.Settings; using StartupHelpers; StartupExtensions.LoadDotEnvFile(); @@ -30,6 +34,17 @@ try builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsAssembly("myai-models"); + sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); + }); + }); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -71,6 +86,13 @@ try app.UseRateLimiter(); app.MapControllers(); + Log.Information("Running EF Core migrations if any"); + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + Log.Information("{Service} startup complete. Listening for requests...", ServiceName); app.Run(); } diff --git a/Apis/api/Services/Contracts/IEmailSender.cs b/Apis/api/Services/Contracts/IEmailSender.cs index bfae8d2..b6b2b27 100644 --- a/Apis/api/Services/Contracts/IEmailSender.cs +++ b/Apis/api/Services/Contracts/IEmailSender.cs @@ -1,4 +1,5 @@ -using Models.Requests; +using CvMatcher.Models.Responses; +using Models.Requests; namespace Api.Services.Contracts { @@ -8,5 +9,7 @@ namespace Api.Services.Contracts Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct); Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct); Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct); + string BuildMatchEmailSubject(int score, string? jobLabel, string language); + string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7); } } diff --git a/Apis/api/Services/SmtpEmailSender.cs b/Apis/api/Services/SmtpEmailSender.cs index 4dd3367..d17ffe7 100644 --- a/Apis/api/Services/SmtpEmailSender.cs +++ b/Apis/api/Services/SmtpEmailSender.cs @@ -6,6 +6,7 @@ using MimeKit; using Models.Settings; using Models.Requests; using CvMatcher.Models.Responses; +using MyAi.Models.Services; namespace Api.Services { @@ -15,27 +16,29 @@ namespace Api.Services private readonly ContactSettings _contact; private readonly SubscribeSettings _subscribe; private readonly FileStorageSettings _fileStorage; + private readonly ITemplateService _templates; private readonly ILogger _log; private readonly string _environmentName; - public SmtpEmailSender(IOptions smtp, + public SmtpEmailSender( + IOptions smtp, IOptions contact, IOptions subscribe, IOptions fileStorage, + ITemplateService templates, ILogger log) { _smtp = smtp.Value; _contact = contact.Value; _subscribe = subscribe.Value; _fileStorage = fileStorage.Value; + _templates = templates; _log = log; - // Use APP_ENVIRONMENT_NAME from environment variable (set in docker-compose) with fallback to "Development" _environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; } public async Task SendContactAsync(ContactRequest req, CancellationToken ct) { - // Throw error if ToEmail is not configured, since contact requests are important to process. if (string.IsNullOrWhiteSpace(_contact.ToEmail)) { _log.LogDebug("Contact email skipped - ToEmail not configured"); @@ -71,7 +74,6 @@ namespace Api.Services public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct) { - // Throw error if ToEmail is not configured, since subscription requests are important to process. if (string.IsNullOrWhiteSpace(_subscribe.ToEmail)) { _log.LogDebug("Subscription email skipped - ToEmail not configured"); @@ -101,7 +103,6 @@ namespace Api.Services public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct) { - // Skip sending if ToEmail is not configured if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail)) { _log.LogDebug("File download notification skipped - ToEmail not configured"); @@ -135,8 +136,6 @@ namespace Api.Services /// private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct) { - // If you're in enterprise environments, you may need to tweak certificate validation. - // Don't disable it casually. var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; _log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}", @@ -212,43 +211,43 @@ namespace Api.Services await SendEmailAsync(msg, "cv match email", ct); _log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient); } - } + } - public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string? jobSearchLink = null) + public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7) { - var body = $@"CV Matcher result + var strengths = result.Strengths?.Count > 0 + ? "- " + string.Join("\n- ", result.Strengths) + : string.Empty; + var gaps = result.Gaps?.Count > 0 + ? "- " + string.Join("\n- ", result.Gaps) + : string.Empty; + var recommendations = result.Recommendations?.Count > 0 + ? "- " + string.Join("\n- ", result.Recommendations) + : string.Empty; -CV Document ID: {cvDocumentId} -Job: {jobLabel ?? "N/A"} -Job URL: {result.JobUrl ?? "N/A"} -Score: {result.Score}% - -Summary: -{result.Summary} - -Strengths: -- {string.Join("\n- ", result.Strengths)} - -Gaps: -- {string.Join("\n- ", result.Gaps)} - -Recommendations: -- {string.Join("\n- ", result.Recommendations)}"; + var body = _templates.Render("email.match.body", language, + ("cvDocumentId", cvDocumentId), + ("jobLabel", jobLabel ?? "N/A"), + ("jobUrl", result.JobUrl ?? "N/A"), + ("score", result.Score.ToString()), + ("summary", result.Summary ?? string.Empty), + ("strengths", strengths), + ("gaps", gaps), + ("recommendations", recommendations)); if (!string.IsNullOrWhiteSpace(jobSearchLink)) { - body += $@" - ---- -Vrei sa gasesti mai multe joburi potrivite CV-ului tau? -Click: {jobSearchLink} -(link valabil 7 zile)"; + body += _templates.Render("email.match.job-search-footer", language, + ("jobSearchLink", jobSearchLink), + ("expiryDays", expiryDays.ToString())); } return body; } - public static string BuildMatchEmailSubject(int score, string? jobLabel) - => $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}"; + public string BuildMatchEmailSubject(int score, string? jobLabel, string language) => + _templates.Render("email.match.subject", language, + ("score", score.ToString()), + ("jobLabel", jobLabel ?? "Job")); } } diff --git a/Apis/api/api.csproj b/Apis/api/api.csproj index 282531a..d369786 100644 --- a/Apis/api/api.csproj +++ b/Apis/api/api.csproj @@ -39,6 +39,7 @@ + diff --git a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs index 4a8f456..a496b0c 100644 --- a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs +++ b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs @@ -4,4 +4,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"; } diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index 1bb13a1..95d832c 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, ct); + var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, ct); return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId }); } catch (Exception ex) diff --git a/Apis/cv-matcher-api/Dockerfile b/Apis/cv-matcher-api/Dockerfile index 0d1c2f9..5803a8e 100644 --- a/Apis/cv-matcher-api/Dockerfile +++ b/Apis/cv-matcher-api/Dockerfile @@ -6,6 +6,7 @@ COPY Apis/cv-matcher-api/cv-matcher-api.csproj Apis/cv-matcher-api/ COPY Apis/cv-search-models/cv-search-models.csproj Apis/cv-search-models/ COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ +COPY Apis/myai-models/myai-models.csproj Apis/myai-models/ COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ @@ -15,6 +16,7 @@ COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/ COPY Apis/cv-search-models/ Apis/cv-search-models/ COPY Apis/shared-models/ Apis/shared-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ +COPY Apis/myai-models/ Apis/myai-models/ COPY Helpers/common-helpers/ Helpers/common-helpers/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ diff --git a/Apis/cv-matcher-api/Program.cs b/Apis/cv-matcher-api/Program.cs index 928a4e4..c0b0f94 100644 --- a/Apis/cv-matcher-api/Program.cs +++ b/Apis/cv-matcher-api/Program.cs @@ -11,6 +11,8 @@ using CvMatcher.Models.Settings; using CvSearch.Models.Data; using CvSearch.Models.Settings; using Microsoft.EntityFrameworkCore; +using MyAi.Models.Data; +using MyAi.Models.Services; using Refit; using Serilog; using Shared.Models.Settings; @@ -74,6 +76,17 @@ try }); }); + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsAssembly("myai-models"); + sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); + }); + }); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -109,6 +122,11 @@ try var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } Log.Information("{Service} startup complete", ServiceName); app.Run(); diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index f49edc4..972aff3 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -2,6 +2,6 @@ namespace Api.Services.Contracts; public interface IJobTokenService { - Task CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct); + Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); Task TriggerStartAsync(string tokenId, CancellationToken ct); } diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index b3e8e3a..5d34651 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -7,6 +7,7 @@ using CvMatcher.Models.Responses; using CvMatcher.Models.Settings; using Api.Services.Contracts; using Microsoft.Extensions.Options; +using MyAi.Models.Services; namespace Api.Services; @@ -17,19 +18,22 @@ public sealed class CvMatcherService : ICvMatcherService private readonly IMatcherAiClient _ai; private readonly IMatcherRepository _repository; private readonly MatcherSettings _settings; + private readonly ITemplateService _templates; public CvMatcherService( IRagApiClient rag, IJobTextExtractor jobTextExtractor, IMatcherAiClient ai, IMatcherRepository repository, - IOptions options) + IOptions options, + ITemplateService templates) { _rag = rag; _jobTextExtractor = jobTextExtractor; _ai = ai; _repository = repository; _settings = options.Value; + _templates = templates; } public async Task UploadCvAsync(IFormFile file, CancellationToken ct) @@ -111,12 +115,8 @@ public sealed class CvMatcherService : ICvMatcherService var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000); var languageName = LanguageName(language); - var systemPrompt = $$""" - You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. - Penalize missing required skills. Do not invent experience. Use concise business language. - Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}. - JSON shape: {"score":number,"summary":"...","strengths":["..."],"gaps":["..."],"recommendations":["..."],"evidence":["..."]} - """; + var systemPrompt = _templates.Render("ai.cv-match.system-prompt", "*", + ("languageName", languageName)); var userPrompt = $""" CV: diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 7ec470b..4640438 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -30,13 +30,14 @@ public sealed class JobTokenService : IJobTokenService _logger = logger; } - public async Task CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct) + public async Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) { var token = new JobSearchTokenEntity { Id = Guid.NewGuid().ToString("N"), CvDocumentId = cvDocumentId, Email = email, + Language = language, ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), Used = false, CreatedAt = DateTime.UtcNow @@ -71,6 +72,7 @@ public sealed class JobTokenService : IJobTokenService TokenId = token.Id, CvDocumentId = token.CvDocumentId, Email = token.Email, + Language = token.Language, Status = JobSearchStatus.Pending, Keywords = keywords, ProviderConfigJson = providerConfigJson, diff --git a/Apis/cv-matcher-api/cv-matcher-api.csproj b/Apis/cv-matcher-api/cv-matcher-api.csproj index edd7c95..5bed5f5 100644 --- a/Apis/cv-matcher-api/cv-matcher-api.csproj +++ b/Apis/cv-matcher-api/cv-matcher-api.csproj @@ -82,5 +82,6 @@ + diff --git a/Apis/cv-search-models/Data/CvSearchDbContext.cs b/Apis/cv-search-models/Data/CvSearchDbContext.cs index 625ebff..2686c2d 100644 --- a/Apis/cv-search-models/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-models/Data/CvSearchDbContext.cs @@ -25,6 +25,7 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.Id).HasMaxLength(64); 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.Used).HasDefaultValue(false); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); }); @@ -40,6 +41,7 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.Status).HasMaxLength(32).IsRequired(); entity.Property(x => x.Keywords).HasMaxLength(1000); entity.Property(x => x.ProviderConfigJson).IsRequired(false); + entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired(); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.HasIndex(x => x.Status); }); diff --git a/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs index 68f31d0..7985a3a 100644 --- a/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs +++ b/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs @@ -9,6 +9,7 @@ public sealed class JobSearchSessionEntity public string Status { get; set; } = JobSearchStatus.Pending; public string Keywords { get; set; } = string.Empty; public string? ProviderConfigJson { get; set; } + public string Language { get; set; } = "en"; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs index 02bab69..08d2f67 100644 --- a/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs +++ b/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs @@ -5,6 +5,7 @@ public sealed class JobSearchTokenEntity public string Id { get; set; } = string.Empty; public string CvDocumentId { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; + public string Language { get; set; } = "en"; public DateTime ExpiresAt { get; set; } public bool Used { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs b/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs new file mode 100644 index 0000000..68602de --- /dev/null +++ b/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs @@ -0,0 +1,174 @@ +// +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 new file mode 100644 index 0000000..ac5ea0b --- /dev/null +++ b/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs @@ -0,0 +1,46 @@ +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 index e4c9e99..5fd3d9e 100644 --- a/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs @@ -98,6 +98,13 @@ namespace CvSearch.Models.Migrations .HasMaxLength(1000) .HasColumnType("nvarchar(1000)"); + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + b.Property("ProviderConfigJson") .HasColumnType("nvarchar(max)"); @@ -142,6 +149,13 @@ namespace CvSearch.Models.Migrations b.Property("ExpiresAt") .HasColumnType("datetime2"); + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + b.Property("Used") .ValueGeneratedOnAdd() .HasColumnType("bit") diff --git a/Apis/myai-models/Data/Entities/TemplateEntity.cs b/Apis/myai-models/Data/Entities/TemplateEntity.cs new file mode 100644 index 0000000..8eb1946 --- /dev/null +++ b/Apis/myai-models/Data/Entities/TemplateEntity.cs @@ -0,0 +1,10 @@ +namespace MyAi.Models.Data.Entities; + +public sealed class TemplateEntity +{ + public string Key { get; set; } = string.Empty; + public string Language { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } +} diff --git a/Apis/myai-models/Data/MyAiDbContext.cs b/Apis/myai-models/Data/MyAiDbContext.cs new file mode 100644 index 0000000..2b1c123 --- /dev/null +++ b/Apis/myai-models/Data/MyAiDbContext.cs @@ -0,0 +1,30 @@ +using MyAi.Models.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MyAi.Models.Data; + +public sealed class MyAiDbContext : DbContext +{ + public const string SchemaName = "myAi"; + public const string MigrationTableName = "_MyAiMigrations"; + + public MyAiDbContext(DbContextOptions options) : base(options) { } + + public DbSet Templates => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(entity => + { + entity.ToTable("Templates"); + entity.HasKey(x => new { x.Key, x.Language }); + entity.Property(x => x.Key).HasMaxLength(128); + entity.Property(x => x.Language).HasMaxLength(8); + entity.Property(x => x.Value).IsRequired(); + entity.Property(x => x.Description).HasMaxLength(500).HasDefaultValue(string.Empty); + entity.Property(x => x.UpdatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + }); + } +} diff --git a/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs b/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs new file mode 100644 index 0000000..1e7b3c9 --- /dev/null +++ b/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs @@ -0,0 +1,62 @@ +// +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 new file mode 100644 index 0000000..299afc6 --- /dev/null +++ b/Apis/myai-models/Migrations/20260524145351_AddTemplates.cs @@ -0,0 +1,113 @@ +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 new file mode 100644 index 0000000..d9a7549 --- /dev/null +++ b/Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs @@ -0,0 +1,59 @@ +// +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 new file mode 100644 index 0000000..a19a48f --- /dev/null +++ b/Apis/myai-models/Services/DbTemplateService.cs @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..50eaad8 --- /dev/null +++ b/Apis/myai-models/Services/ITemplateService.cs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..cf8d4c5 --- /dev/null +++ b/Apis/myai-models/myai-models.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + MyAi.Models + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Jobs/cv-search-job/Dockerfile b/Jobs/cv-search-job/Dockerfile index d1954c9..ada4d68 100644 --- a/Jobs/cv-search-job/Dockerfile +++ b/Jobs/cv-search-job/Dockerfile @@ -7,6 +7,7 @@ COPY Jobs/job-scheduler/job-scheduler.csproj Jobs/job-scheduler/ COPY Apis/cv-search-models/cv-search-models.csproj Apis/cv-search-models/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ +COPY Apis/myai-models/myai-models.csproj Apis/myai-models/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ RUN dotnet restore Jobs/cv-search-job/cv-search-job.csproj @@ -16,6 +17,7 @@ COPY Jobs/job-scheduler/ Jobs/job-scheduler/ COPY Apis/cv-search-models/ Apis/cv-search-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ COPY Apis/shared-models/ Apis/shared-models/ +COPY Apis/myai-models/ Apis/myai-models/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ RUN dotnet publish Jobs/cv-search-job/cv-search-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs index 2fe815d..00733d5 100644 --- a/Jobs/cv-search-job/Program.cs +++ b/Jobs/cv-search-job/Program.cs @@ -9,6 +9,8 @@ using JobScheduler.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using MyAi.Models.Data; +using MyAi.Models.Services; using Refit; using Serilog; using Shared.Models.Settings; @@ -51,6 +53,17 @@ try client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); }); + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsAssembly("myai-models"); + sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); + }); + }); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient(); builder.Services.AddSingleton(); @@ -66,12 +79,17 @@ try host.LogHostStartupDiagnostics(ServiceName); - Log.Information("Running EF Core migrations for CvSearchDbContext"); + Log.Information("Running EF Core migrations"); using (var scope = host.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } Log.Information("{Service} startup complete. Background scheduler is running.", ServiceName); await host.RunAsync(); diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 4a0c531..6c23120 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -5,17 +5,20 @@ using MailKit.Security; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using MimeKit; +using MyAi.Models.Services; namespace CvSearchJob.Services; public sealed class CvSearchEmailSender { private readonly IConfiguration _config; + private readonly ITemplateService _templates; private readonly ILogger _logger; - public CvSearchEmailSender(IConfiguration config, ILogger logger) + public CvSearchEmailSender(IConfiguration config, ITemplateService templates, ILogger logger) { _config = config; + _templates = templates; _logger = logger; } @@ -23,6 +26,7 @@ public sealed class CvSearchEmailSender string toEmail, string? attachmentPath, IReadOnlyList results, + string language, CancellationToken ct) { var smtpHost = _config["Smtp:Host"]; @@ -42,8 +46,9 @@ public sealed class CvSearchEmailSender if (recipients.Count == 0) return; - var body = BuildBody(results); - var subject = $"MyAi.ro: {results.Count} joburi potrivite CV-ului tau"; + var body = BuildBody(results, language); + var subject = _templates.Render("email.search-results.subject", language, + ("count", results.Count.ToString())); var environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; foreach (var recipient in recipients) @@ -77,27 +82,26 @@ public sealed class CvSearchEmailSender } } - private static string BuildBody(IReadOnlyList results) + private string BuildBody(IReadOnlyList results, string language) { if (results.Count == 0) - return "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul."; - - var lines = new System.Text.StringBuilder(); - lines.AppendLine($"MyAi.ro a gasit {results.Count} joburi potrivite CV-ului tau:"); - lines.AppendLine(); + return _templates.Get("email.search-results.empty", language); + var items = new System.Text.StringBuilder(); for (int i = 0; i < results.Count; i++) { var r = results[i]; var matchResp = TryParseResult(r.ResultJson); - lines.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]"); - lines.AppendLine($" {r.JobUrl}"); + items.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]"); + items.AppendLine($" {r.JobUrl}"); if (matchResp is not null && !string.IsNullOrWhiteSpace(matchResp.Summary)) - lines.AppendLine($" {matchResp.Summary}"); - lines.AppendLine(); + items.AppendLine($" {matchResp.Summary}"); + items.AppendLine(); } - return lines.ToString(); + return _templates.Render("email.search-results.body", language, + ("count", results.Count.ToString()), + ("items", items.ToString())); } private static JobMatchResponse? TryParseResult(string json) diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 9f608f3..d2d50c1 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -86,7 +86,7 @@ public sealed class CvSearchJobTask : IJobTask await db.SaveChangesAsync(cancellationToken); var attachmentPath = BuildCvPath(pending.CvDocumentId); - await _emailSender.SendResultsAsync(pending.Email, attachmentPath, results, cancellationToken); + await _emailSender.SendResultsAsync(pending.Email, attachmentPath, results, pending.Language, cancellationToken); _logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count); } catch (Exception ex) diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index 7c38382..097d9fe 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -25,6 +25,7 @@ + diff --git a/myAi.sln b/myAi.sln index db7111f..9a74df4 100644 --- a/myAi.sln +++ b/myAi.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.2.11415.280 @@ -38,72 +39,206 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-job", "Jobs\cv-se EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "job-scheduler", "Jobs\job-scheduler\job-scheduler.csproj", "{A19D2776-B935-BD35-4AB1-3FCE2092805A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "myai-models", "Apis\myai-models\myai-models.csproj", "{3BE2E134-E773-4574-ABDD-175F00E4932E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x64.ActiveCfg = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x64.Build.0 = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x86.ActiveCfg = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x86.Build.0 = Debug|Any CPU {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|Any CPU.Build.0 = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x64.ActiveCfg = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x64.Build.0 = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x86.ActiveCfg = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x86.Build.0 = Release|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x64.ActiveCfg = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x64.Build.0 = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x86.ActiveCfg = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x86.Build.0 = Debug|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|Any CPU.ActiveCfg = Release|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|Any CPU.Build.0 = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x64.ActiveCfg = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x64.Build.0 = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x86.ActiveCfg = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x86.Build.0 = Release|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x64.ActiveCfg = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x64.Build.0 = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x86.ActiveCfg = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x86.Build.0 = Debug|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|Any CPU.ActiveCfg = Release|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|Any CPU.Build.0 = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x64.ActiveCfg = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x64.Build.0 = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x86.ActiveCfg = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x86.Build.0 = Release|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x64.ActiveCfg = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x64.Build.0 = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x86.ActiveCfg = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x86.Build.0 = Debug|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|Any CPU.ActiveCfg = Release|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|Any CPU.Build.0 = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x64.ActiveCfg = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x64.Build.0 = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x86.ActiveCfg = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x86.Build.0 = Release|Any CPU {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|x64.ActiveCfg = Debug|x64 + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|x86.ActiveCfg = Debug|x86 {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.Build.0 = Release|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|x64.ActiveCfg = Release|x64 + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|x86.ActiveCfg = Release|x86 {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x64.ActiveCfg = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x64.Build.0 = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x86.ActiveCfg = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x86.Build.0 = Debug|Any CPU {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|Any CPU.ActiveCfg = Release|Any CPU {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|Any CPU.Build.0 = Release|Any CPU - {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|Any CPU.Build.0 = Release|Any CPU - {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|Any CPU.Build.0 = Release|Any CPU - {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|Any CPU.Build.0 = Release|Any CPU - {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|Any CPU.Build.0 = Release|Any CPU - {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.Build.0 = Release|Any CPU - {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.Build.0 = Release|Any CPU - {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.Build.0 = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x64.ActiveCfg = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x64.Build.0 = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x86.ActiveCfg = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x86.Build.0 = Release|Any CPU {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x64.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x86.Build.0 = Debug|Any CPU {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x64.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x64.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x86.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x86.Build.0 = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x64.ActiveCfg = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x64.Build.0 = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x86.Build.0 = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|Any CPU.Build.0 = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x64.ActiveCfg = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x64.Build.0 = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x86.ActiveCfg = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x86.Build.0 = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x64.ActiveCfg = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x64.Build.0 = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x86.ActiveCfg = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x86.Build.0 = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|Any CPU.Build.0 = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x64.ActiveCfg = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x64.Build.0 = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x86.ActiveCfg = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x86.Build.0 = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x64.ActiveCfg = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x64.Build.0 = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x86.ActiveCfg = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x86.Build.0 = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|Any CPU.Build.0 = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x64.ActiveCfg = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x64.Build.0 = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x86.ActiveCfg = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x86.Build.0 = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x64.ActiveCfg = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x64.Build.0 = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x86.ActiveCfg = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x86.Build.0 = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|Any CPU.Build.0 = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x64.ActiveCfg = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x64.Build.0 = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x86.ActiveCfg = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x86.Build.0 = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x64.ActiveCfg = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x64.Build.0 = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x86.ActiveCfg = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x86.Build.0 = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.Build.0 = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x64.ActiveCfg = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x64.Build.0 = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x86.ActiveCfg = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x86.Build.0 = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x64.ActiveCfg = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x64.Build.0 = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x86.ActiveCfg = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x86.Build.0 = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.Build.0 = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x64.ActiveCfg = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x64.Build.0 = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x86.ActiveCfg = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x86.Build.0 = Release|Any CPU {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x64.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x86.Build.0 = Debug|Any CPU {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x64.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x64.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x86.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x86.Build.0 = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x64.ActiveCfg = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x64.Build.0 = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x86.ActiveCfg = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x86.Build.0 = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.Build.0 = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x64.ActiveCfg = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x64.Build.0 = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x86.ActiveCfg = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x86.Build.0 = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x64.ActiveCfg = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x64.Build.0 = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x86.ActiveCfg = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x86.Build.0 = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|Any CPU.Build.0 = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x64.ActiveCfg = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x64.Build.0 = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x86.ActiveCfg = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -113,15 +248,16 @@ Global {A63E1C1A-4A78-49F4-9F5C-D43783294861} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {C40F5025-B0A6-4B25-B4A2-7EA568E06C40} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {B2C3D4E5-F6A7-4890-BCDE-F01234567890} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {6A1ADA81-28E9-4A64-A32D-0755876D5EB7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {185A8BB0-344A-4856-AEB4-213866EB2EE7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {7446D193-8636-4E58-96E4-0C8CB8790679} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} - {B2C3D4E5-F6A7-4890-BCDE-F01234567890} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} {C3D4E5F6-A7B8-4901-CDEF-012345678901} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} {A19D2776-B935-BD35-4AB1-3FCE2092805A} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} + {3BE2E134-E773-4574-ABDD-175F00E4932E} = {0FE6558F-2157-47F2-A835-558416CE0E2B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67} -- 2.52.0