diff --git a/Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs b/Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs new file mode 100644 index 0000000..1fe2c08 --- /dev/null +++ b/Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs @@ -0,0 +1,6 @@ +namespace CvMatcher.Data.Repositories.Contracts; + +public interface IAiPromptsRepository +{ + Task GetAsync(string key, string language, CancellationToken ct); +} diff --git a/Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs b/Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs new file mode 100644 index 0000000..5cfff67 --- /dev/null +++ b/Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs @@ -0,0 +1,24 @@ +using CvMatcher.Data; +using CvMatcher.Data.Repositories.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace CvMatcher.Data.Repositories; + +public sealed class EfAiPromptsRepository : IAiPromptsRepository +{ + private readonly CvMatcherDbContext _db; + + public EfAiPromptsRepository(CvMatcherDbContext db) + { + _db = db; + } + + public async Task GetAsync(string key, string language, CancellationToken ct) + { + return await _db.AiPrompts + .AsNoTracking() + .Where(x => x.Key == key && x.Language == language) + .Select(x => x.Value) + .FirstOrDefaultAsync(ct); + } +} diff --git a/Apis/cv-matcher-api/Program.cs b/Apis/cv-matcher-api/Program.cs index 0913aec..f247251 100644 --- a/Apis/cv-matcher-api/Program.cs +++ b/Apis/cv-matcher-api/Program.cs @@ -10,8 +10,6 @@ using Api.Services.Contracts; using CvMatcher.Models.Settings; using CvSearch.Data; using Microsoft.EntityFrameworkCore; -using MyAi.Data; -using MyAi.Data.Services; using Refit; using Serilog; using Common.Settings; @@ -76,18 +74,8 @@ try }); }); - builder.Services.AddDbContext(options => - { - var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); - options.UseSqlServer(connectionString, sql => - { - sql.MigrationsAssembly("myai-data"); - sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); - }); - }); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -122,11 +110,6 @@ try var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } - using (var scope = app.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.Migrate(); - } Log.Information("{Service} startup complete", ServiceName); app.Run(); diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 5f699fd..023de6d 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -7,7 +7,6 @@ using CvMatcher.Models.Responses; using CvMatcher.Models.Settings; using Api.Services.Contracts; using Microsoft.Extensions.Options; -using MyAi.Data.Services; namespace Api.Services; @@ -17,23 +16,23 @@ public sealed class CvMatcherService : ICvMatcherService private readonly IJobTextExtractor _jobTextExtractor; private readonly IMatcherAiClient _ai; private readonly IMatcherRepository _repository; + private readonly IAiPromptsRepository _aiPrompts; private readonly MatcherSettings _settings; - private readonly ITemplateService _templates; public CvMatcherService( IRagApiClient rag, IJobTextExtractor jobTextExtractor, IMatcherAiClient ai, IMatcherRepository repository, - IOptions options, - ITemplateService templates) + IAiPromptsRepository aiPrompts, + IOptions options) { _rag = rag; _jobTextExtractor = jobTextExtractor; _ai = ai; _repository = repository; + _aiPrompts = aiPrompts; _settings = options.Value; - _templates = templates; } public async Task UploadCvAsync(IFormFile file, CancellationToken ct) @@ -115,8 +114,9 @@ public sealed class CvMatcherService : ICvMatcherService var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000); var languageName = LanguageName(language); - var systemPrompt = _templates.Render("ai.cv-match.system-prompt", "*", - ("languageName", languageName)); + var promptTemplate = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", "*", ct) + ?? "You are a strict CV-to-job matching engine. Return JSON only."; + var systemPrompt = promptTemplate.Replace("{{languageName}}", languageName, StringComparison.OrdinalIgnoreCase); var userPrompt = $""" CV: diff --git a/Apis/cv-matcher-api/cv-matcher-api.csproj b/Apis/cv-matcher-api/cv-matcher-api.csproj index 561c0ea..f56e350 100644 --- a/Apis/cv-matcher-api/cv-matcher-api.csproj +++ b/Apis/cv-matcher-api/cv-matcher-api.csproj @@ -83,6 +83,5 @@ - diff --git a/Apis/cv-matcher-data/CvMatcherDbContext.cs b/Apis/cv-matcher-data/CvMatcherDbContext.cs index 52e12ce..b6685f0 100644 --- a/Apis/cv-matcher-data/CvMatcherDbContext.cs +++ b/Apis/cv-matcher-data/CvMatcherDbContext.cs @@ -14,6 +14,7 @@ public sealed class CvMatcherDbContext : DbContext public DbSet CvMatchResults => Set(); public DbSet CvMatcherChatCache => Set(); + public DbSet AiPrompts => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -41,5 +42,16 @@ public sealed class CvMatcherDbContext : DbContext entity.Property(x => x.ResponseText).IsRequired(); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("AiPrompts"); + entity.HasKey(x => new { x.Key, x.Language }); + entity.Property(x => x.Key).HasMaxLength(128); + entity.Property(x => x.Language).HasMaxLength(8); + entity.Property(x => x.Value).IsRequired(); + entity.Property(x => x.Description).HasMaxLength(500).HasDefaultValue(string.Empty); + entity.Property(x => x.UpdatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + }); } } diff --git a/Apis/cv-matcher-data/Entities/AiPromptEntity.cs b/Apis/cv-matcher-data/Entities/AiPromptEntity.cs new file mode 100644 index 0000000..47bd670 --- /dev/null +++ b/Apis/cv-matcher-data/Entities/AiPromptEntity.cs @@ -0,0 +1,10 @@ +namespace CvMatcher.Data.Entities; + +public sealed class AiPromptEntity +{ + public string Key { get; set; } = string.Empty; + public string Language { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } +} diff --git a/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs new file mode 100644 index 0000000..2b6bf0b --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs @@ -0,0 +1,130 @@ +// +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("20260528110000_AddAiPrompts")] + partial class AddAiPrompts + { + /// + 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("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId") + .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/20260528110000_AddAiPrompts.cs b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs new file mode 100644 index 0000000..754ee6b --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + public partial class AddAiPrompts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AiPrompts", + schema: "cvMatcher", + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_AiPrompts", x => new { x.Key, x.Language }); + }); + + migrationBuilder.InsertData( + schema: "cvMatcher", + table: "AiPrompts", + columns: ["Key", "Language", "Value", "Description"], + values: new object[] + { + "ai.cv-match.system-prompt", + "*", + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\nPenalize missing required skills. Do not invent experience. Use concise business language.\nRespond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\nJSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"]}", + "System prompt template for the CV-to-job LLM matching call. {{languageName}} is substituted at runtime." + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "AiPrompts", schema: "cvMatcher"); + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs index 8e7ffff..33937c3 100644 --- a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -23,6 +23,37 @@ namespace CvMatcher.Data.Migrations 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")