Compare commits
17 Commits
ef2793448a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 10bac5eb91 | |||
| 65ae4b42da | |||
| 5aaf848423 | |||
| 62654978af | |||
| 7da084c174 | |||
| 27f4cfe21e | |||
| 903fbcd143 | |||
| c5e1b7f687 | |||
| 9b33876c11 | |||
| 2d5572725d | |||
| da1f90449e | |||
| 2192c3f4c5 | |||
| 492859f17f | |||
| a3567ce8e9 | |||
| b52ef8ddff | |||
| d2b12e39ec | |||
| 1e8758796e |
@@ -1,13 +1,19 @@
|
|||||||
name: Build and Push Docker Images Staging
|
name: Build and Push Docker Images
|
||||||
|
|
||||||
|
# Branch-driven deploys — no yaml edits to switch environment:
|
||||||
|
# merge into `staging` -> tag :staging (staging Watchtower deploys)
|
||||||
|
# merge into `production` -> tag :production (production Watchtower deploys)
|
||||||
|
# `main` is the day-to-day work branch and deploys nothing.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- staging
|
- staging
|
||||||
|
- production
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GIT_HOST: git.easysoft.ro
|
GIT_HOST: docker-git.easysoft.ro
|
||||||
REGISTRY_HOST: registry.easysoft.ro
|
REGISTRY_HOST: registry.easysoft.ro
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
API_IMAGE: apps/myai-api
|
API_IMAGE: apps/myai-api
|
||||||
CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api
|
CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api
|
||||||
RAG_API_IMAGE: apps/myai-rag-api
|
RAG_API_IMAGE: apps/myai-rag-api
|
||||||
@@ -16,18 +22,19 @@ env:
|
|||||||
CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job
|
CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job
|
||||||
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
|
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
|
||||||
PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api
|
PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api
|
||||||
IMAGE_TAG: staging
|
IMAGE_TAG: ${{ github.ref_name }} # branch name == image tag (staging | production)
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: host
|
runs-on: host
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout the pushed commit
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REPO_TOKEN }}
|
TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
git clone "http://gelu:${TOKEN}@${GIT_HOST}:3000/${GITHUB_REPOSITORY}.git" .
|
git clone "http://gelu:${TOKEN}@${GIT_HOST}:3000/${GITHUB_REPOSITORY}.git" .
|
||||||
|
git checkout "${{ github.sha }}"
|
||||||
|
|
||||||
- name: Login to registry
|
- name: Login to registry
|
||||||
run: |
|
run: |
|
||||||
@@ -98,3 +105,8 @@ 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 (keep recent build cache)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker image prune -f # dangling only (keep base images)
|
||||||
|
|||||||
@@ -376,3 +376,6 @@ files/
|
|||||||
|
|
||||||
/docker-compose/.env.production
|
/docker-compose/.env.production
|
||||||
/docker-compose/.env.staging
|
/docker-compose/.env.staging
|
||||||
|
|
||||||
|
# local infra access notes (secrets) — never commit
|
||||||
|
ACCESS.md
|
||||||
|
|||||||
@@ -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."]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+138
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
@@ -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."
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,11 +34,15 @@ This applies to both the staging and production repos as appropriate.
|
|||||||
- .NET 10, ASP.NET Core, Worker Service
|
- .NET 10, ASP.NET Core, Worker Service
|
||||||
- Entity Framework Core + SQL Server (multi-schema)
|
- Entity Framework Core + SQL Server (multi-schema)
|
||||||
- Refit for typed HTTP clients between services
|
- Refit for typed HTTP clients between services
|
||||||
- Serilog (JSON structured logging, Console + File + Email sinks)
|
- Serilog — Compact JSON logs to stdout (+ optional email sink); see Observability
|
||||||
- MailKit for SMTP (used exclusively in `email-api`)
|
- MailKit for SMTP (used exclusively in `email-api`)
|
||||||
- Docker Compose for local and production deployment
|
- Docker Compose for local and production deployment
|
||||||
- Watchtower for automatic container updates in production
|
- Watchtower for automatic container updates in production
|
||||||
|
|
||||||
|
## Observability (central stack on monitoring host 10.0.0.156)
|
||||||
|
- **Logs**: every service uses `ConfigureJsonSerilog(ServiceName, appVersion)` (startup-helpers) → Serilog **Compact JSON** to stdout, enriched `Application`/`Environment`/`AppVersion`. The host's Grafana **Alloy** agent ships stdout → **Loki**; view/query in Grafana. No file sink; optional email sink only if `SerilogEmail:*` is configured.
|
||||||
|
- **No app metrics/traces** — these are simple/minimal services, so (unlike easyDent) they don't expose Prometheus metrics or OTLP traces. Container/host metrics still come from the host's cAdvisor/node_exporter.
|
||||||
|
|
||||||
## Project taxonomy
|
## Project taxonomy
|
||||||
|
|
||||||
| Category | Naming | Contains | EF dependency |
|
| Category | Naming | Contains | EF dependency |
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
# Introduction
|
# myAi
|
||||||
TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project.
|
|
||||||
|
|
||||||
# Getting Started
|
The **myai.ro** platform — a set of .NET microservices (CV matching, RAG, email, CV search, page
|
||||||
TODO: Guide users through getting your code up and running on their own system. In this section you can talk about:
|
fetching, …) behind a web frontend + API. Part of the easySoft platform.
|
||||||
1. Installation process
|
|
||||||
2. Software dependencies
|
|
||||||
3. Latest releases
|
|
||||||
4. API references
|
|
||||||
|
|
||||||
# Build and Test
|
## Layout
|
||||||
TODO: Describe and show how to build your code and run the tests.
|
Multiple services (`*-api`, `*-job`) + `web`, sharing a common bootstrap in
|
||||||
|
`startup-helpers/` (Serilog, Swagger, `.env`/Key Vault loading, middleware). See **CLAUDE.md**
|
||||||
|
for the full service map, dependency chain, and conventions.
|
||||||
|
|
||||||
# Contribute
|
## Run locally
|
||||||
TODO: Explain how other users and developers can contribute to make your code better.
|
```bash
|
||||||
|
docker compose up --build # or run individual services with: dotnet run --project <svc>
|
||||||
|
```
|
||||||
|
|
||||||
If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files:
|
## Deploy
|
||||||
- [ASP.NET Core](https://github.com/aspnet/Home)
|
CI builds `registry.easysoft.ro/apps/myai-*:{staging,production}`; Watchtower rolls them out to
|
||||||
- [Visual Studio Code](https://github.com/Microsoft/vscode)
|
the **staging (`10.0.0.183`)** + **production (`10.0.0.248`)** Portainer stacks. Edge Caddy serves
|
||||||
- [Chakra Core](https://github.com/Microsoft/ChakraCore)
|
**myai.ro** (prod) / **myai.easysoft.ro** (staging).
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
Every service: `ConfigureJsonSerilog(name, version)` → Serilog **Compact JSON** to stdout → Grafana
|
||||||
|
**Alloy** → **Loki**. No app metrics/traces (simple services).
|
||||||
|
|
||||||
|
---
|
||||||
|
See **CLAUDE.md** for the detailed solution guide and **ACCESS.md** (local, gitignored) for
|
||||||
|
infrastructure access.
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ Ai__Ollama__ChatModel=llama2
|
|||||||
Ai__Ollama__EmbeddingModel=embedding-model
|
Ai__Ollama__EmbeddingModel=embedding-model
|
||||||
Ai__Ollama__TimeoutSeconds=30
|
Ai__Ollama__TimeoutSeconds=30
|
||||||
|
|
||||||
# Database (shared) - maps to Database:Host etc. used by apps
|
# Database (shared) - maps to Database:Host etc. used by apps.
|
||||||
Database__Host=sqlserver
|
# Deployed (staging/prod) uses the LAN DNS name (resolves to the MSSQL VM 10.0.0.240);
|
||||||
|
# for local dev leave it unset to use the docker-compose 'sqlserver' service default.
|
||||||
|
Database__Host=mssql.easysoft.ro
|
||||||
Database__Port=1433
|
Database__Port=1433
|
||||||
Database__Name=MyAiDb
|
Database__Name=MyAiDb
|
||||||
Database__User=sa
|
Database__User=sa
|
||||||
|
|||||||
@@ -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ă."
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user