diff --git a/Apis/api/Services/EmailApiEmailSender.cs b/Apis/api/Services/EmailApiEmailSender.cs index a886ac6..f44ec06 100644 --- a/Apis/api/Services/EmailApiEmailSender.cs +++ b/Apis/api/Services/EmailApiEmailSender.cs @@ -138,7 +138,7 @@ public sealed class EmailApiEmailSender : IEmailSender IP Address - {userIp ?? "Unknown"} + {userIp ?? _emailTemplates.Get("email.notification.unknown-ip", "en")} """; @@ -215,8 +215,8 @@ public sealed class EmailApiEmailSender : IEmailSender // email.match.body is now stored as HTML in the database var body = _emailTemplates.Render("email.match.body", language, ("cvDocumentId", cvDocumentId), - ("jobLabel", jobLabel ?? "N/A"), - ("jobUrl", result.JobUrl ?? "N/A"), + ("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.fallback-na", language)), + ("jobUrl", result.JobUrl ?? _emailTemplates.Get("email.match.fallback-na", language)), ("score", result.Score.ToString()), ("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)), ("strengths", strengths), @@ -238,7 +238,7 @@ public sealed class EmailApiEmailSender : IEmailSender public string BuildMatchEmailSubject(int score, string? jobLabel, string language) => _emailTemplates.Render("email.match.subject", language, ("score", score.ToString()), - ("jobLabel", jobLabel ?? "Job")); + ("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.subject-fallback-label", language))); public string GetManualJobLabel(string language) => _emailTemplates.Get("email.match.manual-job-label", language); diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 65b7327..d55a219 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -141,7 +141,9 @@ public sealed class CvMatcherService : ICvMatcherService """; var json = await _ai.CreateChatCompletionAsync(systemPrompt, userPrompt, 0.2m, ct); - var result = ParseResult(json); + var errorSummary = await _aiPrompts.GetAsync("parse-error.summary", language, ct); + var errorRec = await _aiPrompts.GetAsync("parse-error.recommendation", language, ct); + var result = ParseResult(json, errorSummary, errorRec); result.JobDocumentId = job.Id; result.JobUrl = job.SourceUrl; result.Cached = false; @@ -153,7 +155,10 @@ public sealed class CvMatcherService : ICvMatcherService /// Deserialises the LLM's JSON output into a . /// Returns a safe fallback response instead of throwing when the JSON cannot be parsed. /// - private static JobMatchResponse ParseResult(string json) + private static JobMatchResponse ParseResult( + string json, + string? errorSummary = null, + string? errorRec = null) { try { @@ -168,8 +173,8 @@ public sealed class CvMatcherService : ICvMatcherService return new JobMatchResponse { Score = 0, - Summary = "The AI response could not be parsed as structured JSON.", - Recommendations = ["Inspect the raw model output and tune the scoring prompt."] + Summary = errorSummary ?? "The AI response could not be parsed as structured JSON.", + Recommendations = [errorRec ?? "Inspect the raw model output and tune the scoring prompt."] }; } diff --git a/Apis/cv-matcher-data/Migrations/20260608193046_AddParseErrorPrompts.Designer.cs b/Apis/cv-matcher-data/Migrations/20260608193046_AddParseErrorPrompts.Designer.cs new file mode 100644 index 0000000..2abb33f --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260608193046_AddParseErrorPrompts.Designer.cs @@ -0,0 +1,138 @@ +// +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("20260608193046_AddParseErrorPrompts")] + partial class AddParseErrorPrompts + { + /// + 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("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId", "Language") + .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/20260608193046_AddParseErrorPrompts.cs b/Apis/cv-matcher-data/Migrations/20260608193046_AddParseErrorPrompts.cs new file mode 100644 index 0000000..d1b15bb --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260608193046_AddParseErrorPrompts.cs @@ -0,0 +1,67 @@ +using CvMatcher.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + public partial class AddParseErrorPrompts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + columns: ["Key", "Language", "Value", "Description"], + values: ["parse-error.summary", "en", "The AI response could not be parsed. Please try again.", "Summary shown in match email when the AI returns an unparseable response"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + columns: ["Key", "Language", "Value", "Description"], + values: ["parse-error.summary", "ro", "Răspunsul AI nu a putut fi interpretat. Vă rugăm să încercați din nou.", "Sumar afișat în emailul de potrivire când AI returnează un răspuns care nu poate fi interpretat"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + columns: ["Key", "Language", "Value", "Description"], + values: ["parse-error.recommendation", "en", "If the problem persists, try a different job link or description.", "Recommendation shown in match email when the AI returns an unparseable response"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + columns: ["Key", "Language", "Value", "Description"], + values: ["parse-error.recommendation", "ro", "Dacă problema persistă, încercați un alt link sau descriere de job.", "Recomandare afișată în emailul de potrivire când AI returnează un răspuns care nu poate fi interpretat"]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["parse-error.summary", "en"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["parse-error.summary", "ro"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["parse-error.recommendation", "en"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["parse-error.recommendation", "ro"]); + } + } +} diff --git a/Apis/email-data/Migrations/20260608192938_AddFallbackStringTemplates.Designer.cs b/Apis/email-data/Migrations/20260608192938_AddFallbackStringTemplates.Designer.cs new file mode 100644 index 0000000..be94fc7 --- /dev/null +++ b/Apis/email-data/Migrations/20260608192938_AddFallbackStringTemplates.Designer.cs @@ -0,0 +1,69 @@ +// +using System; +using Email.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Email.Data.Migrations +{ + [DbContext(typeof(EmailDbContext))] + [Migration("20260608192938_AddFallbackStringTemplates")] + partial class AddFallbackStringTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("email") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", 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("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "email"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-data/Migrations/20260608192938_AddFallbackStringTemplates.cs b/Apis/email-data/Migrations/20260608192938_AddFallbackStringTemplates.cs new file mode 100644 index 0000000..a30b904 --- /dev/null +++ b/Apis/email-data/Migrations/20260608192938_AddFallbackStringTemplates.cs @@ -0,0 +1,163 @@ +using Email.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Email.Data.Migrations +{ + /// + public partial class AddFallbackStringTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.match.fallback-na", "en", "N/A", "Fallback when a match email field (job label or URL) has no value"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.match.fallback-na", "ro", "N/A", "Fallback când un câmp al emailului de potrivire (etichetă job sau URL) nu are valoare"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.match.subject-fallback-label", "en", "Job", "Fallback job label used in match email subject when no specific label is available"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.match.subject-fallback-label", "ro", "Job", "Etichetă fallback pentru subiectul emailului de potrivire când nu există o etichetă specifică"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.notification.unknown-ip", "en", "Unknown", "Fallback IP address label in operator notification emails"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.notification.unknown-ip", "ro", "Necunoscut", "Etichetă fallback pentru adresa IP în emailurile de notificare operator"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.search-results.keywords-empty", "en", "none detected", "Text shown in job search results email when no CV keywords were extracted"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.search-results.keywords-empty", "ro", "niciunul detectat", "Text afișat în emailul cu rezultatele căutării când nu au fost extrase cuvinte cheie din CV"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.search-results.providers-empty", "en", "none", "Text shown in job search results email when no providers were searched"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.search-results.providers-empty", "ro", "niciunul", "Text afișat în emailul cu rezultatele căutării când nu au fost căutați furnizori"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.search-results.location-empty", "en", "-", "Fallback location display in job search results email scan summary"]); + + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: ["email.search-results.location-empty", "ro", "-", "Afișaj fallback pentru locație în sumarului de scanare al emailului cu rezultatele căutării"]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.match.fallback-na", "en"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.match.fallback-na", "ro"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.match.subject-fallback-label", "en"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.match.subject-fallback-label", "ro"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.notification.unknown-ip", "en"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.notification.unknown-ip", "ro"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.keywords-empty", "en"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.keywords-empty", "ro"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.providers-empty", "en"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.providers-empty", "ro"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.location-empty", "en"]); + + migrationBuilder.DeleteData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.location-empty", "ro"]); + } + } +} diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 9ecefc6..be97c5a 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -127,13 +127,15 @@ public sealed class CvSearchEmailSender var keywordsHtml = keywords.Count > 0 ? string.Join(" ", keywords.Select(k => $"{k}")) - : "none detected"; + : $"{_emailTemplates.Get("email.search-results.keywords-empty", language)}"; var providers = providerNames.Count > 0 ? string.Join(", ", providerNames) - : "none"; + : _emailTemplates.Get("email.search-results.providers-empty", language); - var locationDisplay = string.IsNullOrWhiteSpace(location) ? "-" : location; + var locationDisplay = string.IsNullOrWhiteSpace(location) + ? _emailTemplates.Get("email.search-results.location-empty", language) + : location; return _emailTemplates.Render("email.search-results.scan-summary", language, ("keywordsHtml", keywordsHtml), diff --git a/web/wwwroot/js/i18n.js b/web/wwwroot/js/i18n.js index 1e6697a..0235f81 100644 --- a/web/wwwroot/js/i18n.js +++ b/web/wwwroot/js/i18n.js @@ -115,7 +115,10 @@ "cv.noItems": "No items yet.", "cv.strengths": "Strengths", "cv.gaps": "Gaps", - "cv.evidence": "Supporting CV excerpts" + "cv.evidence": "Supporting CV excerpts", + "error.cv_file_missing": "Missing CV PDF.", + "error.captcha_verification_failed": "Captcha verification failed.", + "error.request_cancelled": "Request was cancelled." }, ro: { "brand.subtitle": "prezentare inginerie AI", @@ -224,7 +227,10 @@ "cv.noItems": "Niciun element.", "cv.strengths": "Puncte forte", "cv.gaps": "Lipsuri", - "cv.evidence": "Fragmente relevante din CV" + "cv.evidence": "Fragmente relevante din CV", + "error.cv_file_missing": "Fișierul CV PDF lipsește.", + "error.captcha_verification_failed": "Verificarea captcha a eșuat.", + "error.request_cancelled": "Cererea a fost anulată." } }; })(); diff --git a/web/wwwroot/js/utils/form-helpers.js b/web/wwwroot/js/utils/form-helpers.js index 85786ae..0e20503 100644 --- a/web/wwwroot/js/utils/form-helpers.js +++ b/web/wwwroot/js/utils/form-helpers.js @@ -52,6 +52,7 @@ function isValidEmail(value) { * * Rules: * - 429 (rate limit) → return rateLimitKey translation + * - 4xx with known error code → look up 'error.' in i18n dictionary first * - 4xx with error body → return server's error message (intentional feedback) * - 5xx or no body → return fallbackKey translation * @@ -65,8 +66,17 @@ function extractApiError(body, status, fallbackKey, rateLimitKey) { if (status === 429) { return window.MyAi.t(rateLimitKey || 'form.rateLimited'); } - var msg = body && (body.error || body.Error || body.title); - return (status >= 400 && status < 500 && msg) ? msg : window.MyAi.t(fallbackKey); + if (status >= 400 && status < 500) { + // Prefer i18n translation keyed on the machine-readable error code + if (body && body.code) { + var codeKey = 'error.' + body.code; + var translated = window.MyAi.t(codeKey); + if (translated !== codeKey) return translated; + } + var msg = body && (body.error || body.Error || body.title); + if (msg) return msg; + } + return window.MyAi.t(fallbackKey); } // Expose helpers on window.MyAi for use by other scripts