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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<MyAiDbContext>(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<ITemplateService, DbTemplateService>();
|
||||
|
||||
builder.Services.AddHttpClient<HtmlJobSearcher>();
|
||||
builder.Services.AddSingleton<CvSearchEmailSender>();
|
||||
|
||||
@@ -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<CvSearchDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
using (var scope = host.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("{Service} startup complete. Background scheduler is running.", ServiceName);
|
||||
await host.RunAsync();
|
||||
|
||||
@@ -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<CvSearchEmailSender> _logger;
|
||||
|
||||
public CvSearchEmailSender(IConfiguration config, ILogger<CvSearchEmailSender> logger)
|
||||
public CvSearchEmailSender(IConfiguration config, ITemplateService templates, ILogger<CvSearchEmailSender> logger)
|
||||
{
|
||||
_config = config;
|
||||
_templates = templates;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -23,6 +26,7 @@ public sealed class CvSearchEmailSender
|
||||
string toEmail,
|
||||
string? attachmentPath,
|
||||
IReadOnlyList<JobSearchResultEntity> 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<JobSearchResultEntity> results)
|
||||
private string BuildBody(IReadOnlyList<JobSearchResultEntity> 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<ProjectReference Include="..\..\Apis\shared-models\shared-models.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
<ProjectReference Include="..\job-scheduler\job-scheduler.csproj" />
|
||||
<ProjectReference Include="..\..\Apis\myai-models\myai-models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user