9 Commits

Author SHA1 Message Date
claude da1f90449e Merge observability/compact-json: standardize Serilog to Compact JSON (Application/Environment/AppVersion enrichment) 2026-06-18 10:59:13 +03:00
claude 2192c3f4c5 Logs: Compact JSON + aligned enrichment in shared StartupExtensions
CompactJsonFormatter in both ConfigureJsonSerilog overloads; rename Service->Application,
EnvironmentName->Environment (keep AppVersion). Applies to all myAi services.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:03:55 +03:00
claude 492859f17f ci: prune images + build cache after build (prevent runner disk exhaustion)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:51:31 +03:00
claude a3567ce8e9 Merge pull request 'Fix CV keyword extraction — derive from candidate CV, not matched job' (#55) from feature/fix-keyword-extraction-prompt into main
Build and Push Docker Images Staging / build (push) Successful in 2m23s
Merge PR #55: Fix CV keyword extraction — derive from candidate CV, not matched job
2026-06-09 13:40:10 +00:00
claude b52ef8ddff Fix CV keyword extraction to reflect candidate identity, not matched job
The AI prompt now instructs the LLM to derive keywords entirely from the
candidate's CV (seniority level, primary role title, core technologies they
emphasize) rather than from the job description being matched. This ensures
the job-board search keywords used by cv-search-job represent who the
candidate actually is, not a mirror of the job they happened to match against.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:38:02 +03:00
claude d2b12e39ec Merge PR #54: Fix hardcoded user-facing strings (Issue #53)
Build and Push Docker Images Staging / build (push) Successful in 46s
2026-06-08 22:34:28 +03:00
claude 1e8758796e Fix hardcoded user-facing strings — localize email fallbacks, API errors, AI parse messages
- Frontend: update extractApiError to check body.code first via i18n 'error.<code>' keys;
  add en/ro translations for cv_file_missing, captcha_verification_failed, request_cancelled
- email-data migration: seed 6 fallback template keys (match N/A, subject label, unknown IP,
  job search results empty states for keywords/providers/location)
- EmailApiEmailSender: replace "N/A", "Job", "Unknown" literals with template lookups
- CvSearchEmailSender: replace "none detected", "none", "-" literals with template lookups
- cv-matcher-data migration: seed parse-error.summary and parse-error.recommendation in AiPrompts
- CvMatcherService: look up localized parse-error messages from AiPrompts before calling ParseResult

Closes #53

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:32:03 +03:00
claude ef2793448a Fix: declare language before jobLabel in CvMatcherController
Build and Push Docker Images Staging / build (push) Successful in 8m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:10:22 +03:00
claude cbf06031e8 Merge pull request 'Fix language consistency in job search and match emails' (#52) from feature/language-consistency into main
Build and Push Docker Images Staging / build (push) Failing after 32s
Merge PR #52: Fix language consistency in job search and match emails
2026-06-08 19:08:28 +00:00
16 changed files with 722 additions and 26 deletions
+6
View File
@@ -98,3 +98,9 @@ jobs:
- name: Push Page Fetcher API image - name: Push Page Fetcher API image
run: | run: |
docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}" docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}"
- name: Reclaim disk space
if: always()
run: |
docker image prune -af
docker builder prune -af
+1 -2
View File
@@ -170,12 +170,11 @@ public sealed class CvMatcherController : ControllerBase
!string.IsNullOrWhiteSpace(request.JobDescription)); !string.IsNullOrWhiteSpace(request.JobDescription));
var res = await _cvApi.MatchJob(request, ct); var res = await _cvApi.MatchJob(request, ct);
var attachmentPath = TryGetCachedCvPath(request.CvDocumentId); var attachmentPath = TryGetCachedCvPath(request.CvDocumentId);
var language = NormalizeLanguage(request.Language);
var jobLabel = !string.IsNullOrWhiteSpace(request.JobUrl) var jobLabel = !string.IsNullOrWhiteSpace(request.JobUrl)
? request.JobUrl ? request.JobUrl
: _emailSender.GetManualJobLabel(language); : _emailSender.GetManualJobLabel(language);
var language = NormalizeLanguage(request.Language);
string? jobSearchLink = null; string? jobSearchLink = null;
if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId)) if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId))
{ {
+4 -4
View File
@@ -138,7 +138,7 @@ public sealed class EmailApiEmailSender : IEmailSender
</tr> </tr>
<tr style="background:#f8f9fa"> <tr style="background:#f8f9fa">
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">IP Address</td> <td style="font-weight:600;border:1px solid #dee2e6;color:#495057">IP Address</td>
<td style="border:1px solid #dee2e6">{userIp ?? "Unknown"}</td> <td style="border:1px solid #dee2e6">{userIp ?? _emailTemplates.Get("email.notification.unknown-ip", "en")}</td>
</tr> </tr>
</table> </table>
"""; """;
@@ -215,8 +215,8 @@ public sealed class EmailApiEmailSender : IEmailSender
// email.match.body is now stored as HTML in the database // email.match.body is now stored as HTML in the database
var body = _emailTemplates.Render("email.match.body", language, var body = _emailTemplates.Render("email.match.body", language,
("cvDocumentId", cvDocumentId), ("cvDocumentId", cvDocumentId),
("jobLabel", jobLabel ?? "N/A"), ("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.fallback-na", language)),
("jobUrl", result.JobUrl ?? "N/A"), ("jobUrl", result.JobUrl ?? _emailTemplates.Get("email.match.fallback-na", language)),
("score", result.Score.ToString()), ("score", result.Score.ToString()),
("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)), ("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
("strengths", strengths), ("strengths", strengths),
@@ -238,7 +238,7 @@ public sealed class EmailApiEmailSender : IEmailSender
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) => public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
_emailTemplates.Render("email.match.subject", language, _emailTemplates.Render("email.match.subject", language,
("score", score.ToString()), ("score", score.ToString()),
("jobLabel", jobLabel ?? "Job")); ("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.subject-fallback-label", language)));
public string GetManualJobLabel(string language) => public string GetManualJobLabel(string language) =>
_emailTemplates.Get("email.match.manual-job-label", language); _emailTemplates.Get("email.match.manual-job-label", language);
@@ -141,7 +141,9 @@ public sealed class CvMatcherService : ICvMatcherService
"""; """;
var json = await _ai.CreateChatCompletionAsync(systemPrompt, userPrompt, 0.2m, ct); 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.JobDocumentId = job.Id;
result.JobUrl = job.SourceUrl; result.JobUrl = job.SourceUrl;
result.Cached = false; result.Cached = false;
@@ -153,7 +155,10 @@ public sealed class CvMatcherService : ICvMatcherService
/// Deserialises the LLM's JSON output into a <see cref="JobMatchResponse"/>. /// Deserialises the LLM's JSON output into a <see cref="JobMatchResponse"/>.
/// Returns a safe fallback response instead of throwing when the JSON cannot be parsed. /// Returns a safe fallback response instead of throwing when the JSON cannot be parsed.
/// </summary> /// </summary>
private static JobMatchResponse ParseResult(string json) private static JobMatchResponse ParseResult(
string json,
string? errorSummary = null,
string? errorRec = null)
{ {
try try
{ {
@@ -168,8 +173,8 @@ public sealed class CvMatcherService : ICvMatcherService
return new JobMatchResponse return new JobMatchResponse
{ {
Score = 0, Score = 0,
Summary = "The AI response could not be parsed as structured JSON.", Summary = errorSummary ?? "The AI response could not be parsed as structured JSON.",
Recommendations = ["Inspect the raw model output and tune the scoring prompt."] Recommendations = [errorRec ?? "Inspect the raw model output and tune the scoring prompt."]
}; };
} }
@@ -0,0 +1,138 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("AiPrompts", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("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<string>("CacheKey")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("ResponseText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Temperature")
.HasColumnType("decimal(4,2)");
b.HasKey("CacheKey");
b.ToTable("ChatCache", "cvMatcher");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,67 @@
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class AddParseErrorPrompts : Migration
{
/// <inheritdoc />
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"]);
}
/// <inheritdoc />
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"]);
}
}
}
@@ -0,0 +1,138 @@
// <auto-generated />
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("20260609133623_FixKeywordExtractionPrompt")]
partial class FixKeywordExtractionPrompt
{
/// <inheritdoc />
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<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("AiPrompts", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("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<string>("CacheKey")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("ResponseText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Temperature")
.HasColumnType("decimal(4,2)");
b.HasKey("CacheKey");
b.ToTable("ChatCache", "cvMatcher");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,93 @@
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class FixKeywordExtractionPrompt : Migration
{
// Full prompt values — only the 'keywords' instruction changes vs. the previous migration.
// Stored in full so Down() can restore the previous version exactly.
private const string EnNew =
"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. All text fields in the JSON response must be in English.\n" +
"JSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"City, Country\"}.\n" +
"For 'keywords': extract 2-4 job-board search terms that represent the candidate's professional identity as shown in their CV — their seniority level and primary role title (e.g. 'Software Architect', 'Engineering Manager', 'Senior .NET Developer') plus 1-2 core technologies they genuinely emphasize throughout the CV. Derive these entirely from the CV — do not use the job title or job technologies unless they independently match the candidate's actual positioning. Avoid generic terms like 'developer', 'engineer', 'cloud', or 'leadership'.\n" +
"For 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.";
private const string EnPrev =
"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. All text fields in the JSON response must be in English.\n" +
"JSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"City, Country\"}.\n" +
"For 'keywords': extract 2-4 short, concrete terms a recruiter would search for on a job board — the candidate's primary role title and key technologies (e.g. 'Senior .NET Developer', 'C#', 'Azure'). Avoid abstract concepts like 'leadership', 'cloud', or 'microservices'.\n" +
"For 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.";
private const string RoNew =
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\n" +
"JSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"Oraș, Țară\"}.\n" +
"Pentru 'keywords': extrage 2-4 termeni de căutare pe site-uri de joburi care reprezintă identitatea profesională a candidatului conform CV-ului — nivelul de senioritate și titlul principal de rol (ex. 'Software Architect', 'Engineering Manager', 'Senior .NET Developer') și 1-2 tehnologii de bază pe care candidatul le evidențiază cu adevărat în CV. Derivă aceștia exclusiv din CV — nu folosi titlul jobului sau tehnologiile din job dacă nu corespund poziționării reale a candidatului. Evită termeni generici precum 'developer', 'engineer', 'cloud' sau 'leadership'.\n" +
"Pentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.";
private const string RoPrev =
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\n" +
"JSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"Oraș, Țară\"}.\n" +
"Pentru 'keywords': extrage 2-4 termeni scurți și concreți pe care un recrutor i-ar căuta pe un site de joburi — titlul principal al rolului și tehnologiile cheie (ex. 'Senior .NET Developer', 'C#', 'Azure'). Evită concepte abstracte precum 'leadership', 'cloud' sau 'microservicii'.\n" +
"Pentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.";
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Update English prompt: keywords must now be derived from the CV only,
// not influenced by the job description being matched against.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "en"],
columns: ["Value", "Description"],
values: [
EnNew,
"System prompt for CV-to-job matching in English. Keywords represent the candidate's CV identity (seniority + role + core tech), not the job being matched."
]);
// Update Romanian prompt: same improvement.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
RoNew,
"System prompt pentru potrivire CV-job în română. Cuvintele cheie reprezintă identitatea CV-ului candidatului (senioritate + rol + tehnologii cheie), nu jobul cu care se face potrivirea."
]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "en"],
columns: ["Value", "Description"],
values: [
EnPrev,
"System prompt for CV-to-job matching in English. Extracts job-board-friendly keywords (role title + key tech) and candidate location."
]);
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
RoPrev,
"System prompt pentru potrivire CV-job în limba română. Extrage cuvinte cheie prietenoase pentru site-uri de joburi (titlu rol + tehnologii cheie) și locația candidatului."
]);
}
}
}
@@ -0,0 +1,69 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("OperatorCopy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("Templates", "email");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,163 @@
using Email.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Email.Data.Migrations
{
/// <inheritdoc />
public partial class AddFallbackStringTemplates : Migration
{
/// <inheritdoc />
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"]);
}
/// <inheritdoc />
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"]);
}
}
}
+1
View File
@@ -23,6 +23,7 @@
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" /> <PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.Email" Version="4.2.1" /> <PackageVersion Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<!-- Swagger --> <!-- Swagger -->
+6 -8
View File
@@ -39,11 +39,10 @@ public static class StartupExtensions
.ReadFrom.Configuration(context.Configuration) .ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services) .ReadFrom.Services(services)
.Enrich.FromLogContext() .Enrich.FromLogContext()
.Enrich.WithMachineName() .Enrich.WithProperty("Application", serviceName)
.Enrich.WithEnvironmentName() .Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName)
.Enrich.WithProperty("Service", serviceName)
.Enrich.WithProperty("AppVersion", appVersion) .Enrich.WithProperty("AppVersion", appVersion)
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()); .WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter());
AddEmailSinkIfConfigured(configuration, context.Configuration, serviceName); AddEmailSinkIfConfigured(configuration, context.Configuration, serviceName);
}); });
@@ -57,11 +56,10 @@ public static class StartupExtensions
.ReadFrom.Configuration(builder.Configuration) .ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services) .ReadFrom.Services(services)
.Enrich.FromLogContext() .Enrich.FromLogContext()
.Enrich.WithMachineName() .Enrich.WithProperty("Application", serviceName)
.Enrich.WithEnvironmentName() .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
.Enrich.WithProperty("Service", serviceName)
.Enrich.WithProperty("AppVersion", appVersion) .Enrich.WithProperty("AppVersion", appVersion)
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()); .WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter());
AddEmailSinkIfConfigured(configuration, builder.Configuration, serviceName); AddEmailSinkIfConfigured(configuration, builder.Configuration, serviceName);
}); });
@@ -17,6 +17,7 @@
<PackageReference Include="DotNetEnv" /> <PackageReference Include="DotNetEnv" />
<PackageReference Include="Serilog.AspNetCore" /> <PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Environment" /> <PackageReference Include="Serilog.Enrichers.Environment" />
<PackageReference Include="Serilog.Formatting.Compact" />
<PackageReference Include="Serilog.Sinks.Email" /> <PackageReference Include="Serilog.Sinks.Email" />
<PackageReference Include="Serilog.Sinks.File" /> <PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Swashbuckle.AspNetCore" /> <PackageReference Include="Swashbuckle.AspNetCore" />
@@ -127,13 +127,15 @@ public sealed class CvSearchEmailSender
var keywordsHtml = keywords.Count > 0 var keywordsHtml = keywords.Count > 0
? string.Join(" ", keywords.Select(k => ? string.Join(" ", keywords.Select(k =>
$"<span style=\"display:inline-block;background:#e9ecef;padding:2px 8px;margin:2px 2px 2px 0;font-size:12px;\">{k}</span>")) $"<span style=\"display:inline-block;background:#e9ecef;padding:2px 8px;margin:2px 2px 2px 0;font-size:12px;\">{k}</span>"))
: "<span style=\"color:#6c757d;font-size:12px;font-style:italic;\">none detected</span>"; : $"<span style=\"color:#6c757d;font-size:12px;font-style:italic;\">{_emailTemplates.Get("email.search-results.keywords-empty", language)}</span>";
var providers = providerNames.Count > 0 var providers = providerNames.Count > 0
? string.Join(", ", providerNames) ? 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, return _emailTemplates.Render("email.search-results.scan-summary", language,
("keywordsHtml", keywordsHtml), ("keywordsHtml", keywordsHtml),
+8 -2
View File
@@ -115,7 +115,10 @@
"cv.noItems": "No items yet.", "cv.noItems": "No items yet.",
"cv.strengths": "Strengths", "cv.strengths": "Strengths",
"cv.gaps": "Gaps", "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: { ro: {
"brand.subtitle": "prezentare inginerie AI", "brand.subtitle": "prezentare inginerie AI",
@@ -224,7 +227,10 @@
"cv.noItems": "Niciun element.", "cv.noItems": "Niciun element.",
"cv.strengths": "Puncte forte", "cv.strengths": "Puncte forte",
"cv.gaps": "Lipsuri", "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ă."
} }
}; };
})(); })();
+12 -2
View File
@@ -52,6 +52,7 @@ function isValidEmail(value) {
* *
* Rules: * Rules:
* - 429 (rate limit) → return rateLimitKey translation * - 429 (rate limit) → return rateLimitKey translation
* - 4xx with known error code → look up 'error.<code>' in i18n dictionary first
* - 4xx with error body → return server's error message (intentional feedback) * - 4xx with error body → return server's error message (intentional feedback)
* - 5xx or no body → return fallbackKey translation * - 5xx or no body → return fallbackKey translation
* *
@@ -65,8 +66,17 @@ function extractApiError(body, status, fallbackKey, rateLimitKey) {
if (status === 429) { if (status === 429) {
return window.MyAi.t(rateLimitKey || 'form.rateLimited'); return window.MyAi.t(rateLimitKey || 'form.rateLimited');
} }
var msg = body && (body.error || body.Error || body.title); if (status >= 400 && status < 500) {
return (status >= 400 && status < 500 && msg) ? msg : window.MyAi.t(fallbackKey); // 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 // Expose helpers on window.MyAi for use by other scripts