Changes
Build and Push Docker Images / build (push) Successful in 42s

This commit is contained in:
2026-05-04 15:56:15 +03:00
parent 540720e771
commit 2dce2ab0ff
14 changed files with 656 additions and 5 deletions
+12
View File
@@ -70,3 +70,15 @@ Serilog__WriteTo__2__Args__networkCredential__password=your-password
Serilog__WriteTo__2__Args__port=587
Serilog__WriteTo__2__Args__enableSsl=true
# OpenAI / RAG CV Matcher
OpenAI__ApiKey=sk-your-openai-api-key
OpenAI__ChatModel=gpt-4o-mini
OpenAI__EmbeddingModel=text-embedding-3-small
OpenAI__TimeoutSeconds=60
Rag__MaxPdfSizeMb=5
Rag__ChunkSize=900
Rag__ChunkOverlap=150
Rag__CvTtlMinutes=60
Rag__MaxJobTextChars=20000
Rag__TopK=6
+61
View File
@@ -0,0 +1,61 @@
using Api.Models.Rag;
using Api.Services.Rag;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Api.Controllers;
[ApiController]
[Route("api/rag")]
[EnableRateLimiting("rag")]
public sealed class RagController : ControllerBase
{
private readonly ICvRagService _cvRagService;
private readonly ILogger<RagController> _logger;
public RagController(ICvRagService cvRagService, ILogger<RagController> logger)
{
_cvRagService = cvRagService;
_logger = logger;
}
[HttpPost("cv")]
[RequestSizeLimit(8 * 1024 * 1024)]
public async Task<IActionResult> UploadCv([FromForm(Name = "cv")] IFormFile? cv, [FromForm] bool gdprConsent, CancellationToken ct)
{
try
{
if (cv is null) return BadRequest(new { error = "Missing CV PDF." });
var result = await _cvRagService.IngestCvAsync(cv, gdprConsent, ct);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "CV ingestion failed");
return StatusCode(500, new { error = "CV ingestion failed." });
}
}
[HttpPost("match-job")]
public async Task<IActionResult> MatchJob([FromBody] JobMatchRequest request, CancellationToken ct)
{
try
{
var result = await _cvRagService.MatchJobAsync(request, ct);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Job matching failed");
return StatusCode(500, new { error = "Job matching failed." });
}
}
}
+43
View File
@@ -0,0 +1,43 @@
namespace Api.Models.Rag;
public sealed record CvIngestResponse(
string DocumentId,
int Chunks,
int CharactersExtracted,
string Summary
);
public sealed class JobMatchRequest
{
public string? CvDocumentId { get; set; }
public string? JobUrl { get; set; }
public string? JobDescription { get; set; }
public bool GdprConsent { get; set; }
}
public sealed class JobMatchResponse
{
public int Score { get; set; }
public string Summary { get; set; } = string.Empty;
public List<string> Strengths { get; set; } = [];
public List<string> Gaps { get; set; } = [];
public List<string> Recommendations { get; set; } = [];
public List<string> Evidence { get; set; } = [];
}
public sealed class StoredCvChunk
{
public required string Id { get; init; }
public required string DocumentId { get; init; }
public required string Text { get; init; }
public required float[] Embedding { get; init; }
public required int ChunkIndex { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
public sealed class RetrievedCvChunk
{
public required string Text { get; init; }
public required int ChunkIndex { get; init; }
public double Score { get; init; }
}
+24
View File
@@ -75,11 +75,19 @@ try
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
builder.Services.Configure<RagSettings>(builder.Configuration.GetSection("Rag"));
builder.Services.Configure<OpenAiSettings>(builder.Configuration.GetSection("OpenAI"));
// Services
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
builder.Services.AddSingleton<Api.Services.Rag.IPdfTextExtractor, Api.Services.Rag.PdfTextExtractor>();
builder.Services.AddSingleton<Api.Services.Rag.ITextChunker, Api.Services.Rag.TextChunker>();
builder.Services.AddSingleton<Api.Services.Rag.ICvVectorStore, Api.Services.Rag.InMemoryCvVectorStore>();
builder.Services.AddScoped<Api.Services.Rag.ICvRagService, Api.Services.Rag.CvRagService>();
builder.Services.AddHttpClient<Api.Services.Rag.IOpenAiRagClient, Api.Services.Rag.OpenAiRagClient>();
builder.Services.AddHttpClient<Api.Services.Rag.IJobTextExtractor, Api.Services.Rag.JobTextExtractor>();
// Swagger
builder.Services.AddEndpointsApiExplorer();
@@ -162,6 +170,22 @@ try
);
});
// Policy: CV matcher, expensive because it calls AI APIs.
options.AddPolicy("rag", httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ip,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(10),
QueueLimit = 0,
AutoReplenishment = true
}
);
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, ct) =>
+169
View File
@@ -0,0 +1,169 @@
using Api.Models.Rag;
using Api.Settings;
using Microsoft.Extensions.Options;
using System.Text.Json;
namespace Api.Services.Rag;
public interface ICvRagService
{
Task<CvIngestResponse> IngestCvAsync(IFormFile file, bool gdprConsent, CancellationToken ct);
Task<JobMatchResponse> MatchJobAsync(JobMatchRequest request, CancellationToken ct);
}
public sealed class CvRagService : ICvRagService
{
private readonly IPdfTextExtractor _pdfTextExtractor;
private readonly ITextChunker _textChunker;
private readonly IOpenAiRagClient _openAi;
private readonly ICvVectorStore _store;
private readonly IJobTextExtractor _jobTextExtractor;
private readonly RagSettings _settings;
private readonly ILogger<CvRagService> _logger;
public CvRagService(
IPdfTextExtractor pdfTextExtractor,
ITextChunker textChunker,
IOpenAiRagClient openAi,
ICvVectorStore store,
IJobTextExtractor jobTextExtractor,
IOptions<RagSettings> options,
ILogger<CvRagService> logger)
{
_pdfTextExtractor = pdfTextExtractor;
_textChunker = textChunker;
_openAi = openAi;
_store = store;
_jobTextExtractor = jobTextExtractor;
_settings = options.Value;
_logger = logger;
}
public async Task<CvIngestResponse> IngestCvAsync(IFormFile file, bool gdprConsent, CancellationToken ct)
{
if (!gdprConsent) throw new InvalidOperationException("GDPR consent is required.");
if (file.Length == 0) throw new InvalidOperationException("CV PDF is empty.");
if (file.Length > _settings.MaxPdfSizeMb * 1024L * 1024L) throw new InvalidOperationException($"PDF is too large. Max size is {_settings.MaxPdfSizeMb} MB.");
if (!string.Equals(Path.GetExtension(file.FileName), ".pdf", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Only PDF files are accepted.");
await using var stream = file.OpenReadStream();
var text = _pdfTextExtractor.ExtractText(stream);
if (text.Length < 80) throw new InvalidOperationException("Could not extract enough text from this PDF.");
var documentId = $"cv_{Guid.NewGuid():N}";
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(Math.Max(10, _settings.CvTtlMinutes));
var chunks = _textChunker.Chunk(text, _settings.ChunkSize, _settings.ChunkOverlap);
var stored = new List<StoredCvChunk>();
for (var i = 0; i < chunks.Count; i++)
{
ct.ThrowIfCancellationRequested();
stored.Add(new StoredCvChunk
{
Id = Guid.NewGuid().ToString("N"),
DocumentId = documentId,
Text = chunks[i],
Embedding = await _openAi.CreateEmbeddingAsync(chunks[i], ct),
ChunkIndex = i,
ExpiresAt = expiresAt
});
}
_store.Save(documentId, stored);
var summary = await SummarizeCvAsync(text, ct);
return new CvIngestResponse(documentId, stored.Count, text.Length, summary);
}
public async Task<JobMatchResponse> MatchJobAsync(JobMatchRequest request, CancellationToken ct)
{
if (!request.GdprConsent) throw new InvalidOperationException("GDPR consent is required.");
if (string.IsNullOrWhiteSpace(request.CvDocumentId)) throw new InvalidOperationException("Missing CV document id.");
var cvChunks = _store.Get(request.CvDocumentId);
if (cvChunks.Count == 0) throw new InvalidOperationException("CV context was not found or has expired. Upload the CV again.");
var jobText = await _jobTextExtractor.ExtractAsync(request.JobUrl, request.JobDescription, ct);
if (jobText.Length < 80) throw new InvalidOperationException("Could not extract enough job text. Paste the job description manually.");
var jobEmbedding = await _openAi.CreateEmbeddingAsync(jobText, ct);
var retrieved = _store.Search(request.CvDocumentId, jobEmbedding, _settings.TopK);
var cvContext = string.Join("\n\n", retrieved.Select(x => $"CV chunk {x.ChunkIndex} | similarity {x.Score:0.000}:\n{x.Text}"));
var systemPrompt = "You are a strict senior technical recruiter and AI CV matcher. Return only valid JSON. Do not invent candidate experience. Use only the supplied CV context and job text.";
var userPrompt = $$"""
Compare the candidate CV context with the job description.
Return this JSON shape exactly:
{
"score": 0,
"summary": "short direct assessment",
"strengths": ["strength 1"],
"gaps": ["gap 1"],
"recommendations": ["action 1"],
"evidence": ["short CV evidence quote or paraphrase"]
}
Score must be 0-100.
CV CONTEXT:
{{cvContext}}
JOB DESCRIPTION:
{{jobText}}
""";
var content = await _openAi.CreateChatCompletionAsync(systemPrompt, userPrompt, ct);
var response = ParseMatchResponse(content);
if (response.Evidence.Count == 0)
{
response.Evidence = retrieved.Select(x => x.Text.Length > 280 ? x.Text[..280] + "..." : x.Text).ToList();
}
return response;
}
private async Task<string> SummarizeCvAsync(string cvText, CancellationToken ct)
{
try
{
var shortened = cvText.Length > 8000 ? cvText[..8000] : cvText;
var content = await _openAi.CreateChatCompletionAsync(
"Return only valid JSON.",
$$"""
Summarize this CV in one concise sentence. Return JSON: { "summary": "..." }
CV:
{{shortened}}
""",
ct);
using var doc = JsonDocument.Parse(content);
return doc.RootElement.TryGetProperty("summary", out var summary) ? summary.GetString() ?? "CV indexed." : "CV indexed.";
}
catch (Exception ex)
{
_logger.LogWarning(ex, "CV summary failed");
return "CV indexed.";
}
}
private static JobMatchResponse ParseMatchResponse(string content)
{
try
{
var response = JsonSerializer.Deserialize<JobMatchResponse>(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new JobMatchResponse();
response.Score = Math.Clamp(response.Score, 0, 100);
response.Strengths ??= [];
response.Gaps ??= [];
response.Recommendations ??= [];
response.Evidence ??= [];
return response;
}
catch
{
return new JobMatchResponse
{
Score = 0,
Summary = "The AI response could not be parsed. Check logs and prompt output.",
Gaps = ["Invalid JSON returned by the model."],
Evidence = []
};
}
}
}
+79
View File
@@ -0,0 +1,79 @@
using Api.Models.Rag;
namespace Api.Services.Rag;
public interface ICvVectorStore
{
void Save(string documentId, IEnumerable<StoredCvChunk> chunks);
IReadOnlyList<StoredCvChunk> Get(string documentId);
IReadOnlyList<RetrievedCvChunk> Search(string documentId, float[] queryEmbedding, int topK);
}
public sealed class InMemoryCvVectorStore : ICvVectorStore
{
private readonly object _lock = new();
private readonly Dictionary<string, List<StoredCvChunk>> _store = new(StringComparer.OrdinalIgnoreCase);
public void Save(string documentId, IEnumerable<StoredCvChunk> chunks)
{
lock (_lock)
{
CleanupExpiredUnsafe();
_store[documentId] = chunks.ToList();
}
}
public IReadOnlyList<StoredCvChunk> Get(string documentId)
{
lock (_lock)
{
CleanupExpiredUnsafe();
return _store.TryGetValue(documentId, out var chunks) ? chunks.ToList() : [];
}
}
public IReadOnlyList<RetrievedCvChunk> Search(string documentId, float[] queryEmbedding, int topK)
{
var chunks = Get(documentId);
if (chunks.Count == 0) return [];
return chunks
.Select(chunk => new RetrievedCvChunk
{
Text = chunk.Text,
ChunkIndex = chunk.ChunkIndex,
Score = CosineSimilarity(queryEmbedding, chunk.Embedding)
})
.OrderByDescending(x => x.Score)
.Take(Math.Clamp(topK, 1, 12))
.ToList();
}
private void CleanupExpiredUnsafe()
{
var now = DateTimeOffset.UtcNow;
foreach (var key in _store.Where(x => x.Value.All(c => c.ExpiresAt <= now)).Select(x => x.Key).ToList())
{
_store.Remove(key);
}
}
private static double CosineSimilarity(float[] a, float[] b)
{
if (a.Length != b.Length || a.Length == 0) return 0;
double dot = 0;
double magA = 0;
double magB = 0;
for (var i = 0; i < a.Length; i++)
{
dot += a[i] * b[i];
magA += a[i] * a[i];
magB += b[i] * b[i];
}
if (magA == 0 || magB == 0) return 0;
return dot / (Math.Sqrt(magA) * Math.Sqrt(magB));
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Net;
using System.Text.RegularExpressions;
using Api.Settings;
using Microsoft.Extensions.Options;
namespace Api.Services.Rag;
public interface IJobTextExtractor
{
Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct);
}
public sealed class JobTextExtractor : IJobTextExtractor
{
private readonly HttpClient _httpClient;
private readonly RagSettings _settings;
public JobTextExtractor(HttpClient httpClient, IOptions<RagSettings> options)
{
_httpClient = httpClient;
_settings = options.Value;
_httpClient.Timeout = TimeSpan.FromSeconds(20);
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0");
}
public async Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct)
{
var pasted = Normalize(jobDescription ?? string.Empty);
if (!string.IsNullOrWhiteSpace(pasted)) return Limit(pasted);
if (string.IsNullOrWhiteSpace(jobUrl)) return string.Empty;
if (!Uri.TryCreate(jobUrl, UriKind.Absolute, out var uri) || (uri.Scheme != "http" && uri.Scheme != "https"))
{
throw new InvalidOperationException("Invalid job URL.");
}
var html = await _httpClient.GetStringAsync(uri, ct);
html = Regex.Replace(html, "<script[\\s\\S]*?</script>", " ", RegexOptions.IgnoreCase);
html = Regex.Replace(html, "<style[\\s\\S]*?</style>", " ", RegexOptions.IgnoreCase);
html = Regex.Replace(html, "<[^>]+>", " ");
var text = WebUtility.HtmlDecode(html);
return Limit(Normalize(text));
}
private string Limit(string value)
{
var max = Math.Max(4000, _settings.MaxJobTextChars);
return value.Length <= max ? value : value[..max];
}
private static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var parts = value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
return string.Join(' ', parts).Trim();
}
}
+104
View File
@@ -0,0 +1,104 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Api.Settings;
using Microsoft.Extensions.Options;
namespace Api.Services.Rag;
public interface IOpenAiRagClient
{
Task<float[]> CreateEmbeddingAsync(string input, CancellationToken ct);
Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, CancellationToken ct);
}
public sealed class OpenAiRagClient : IOpenAiRagClient
{
private readonly HttpClient _httpClient;
private readonly OpenAiSettings _settings;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public OpenAiRagClient(HttpClient httpClient, IOptions<OpenAiSettings> options)
{
_httpClient = httpClient;
_settings = options.Value;
if (!string.IsNullOrWhiteSpace(_settings.ApiKey))
{
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _settings.ApiKey);
}
_httpClient.Timeout = TimeSpan.FromSeconds(Math.Max(15, _settings.TimeoutSeconds));
_httpClient.BaseAddress = new Uri("https://api.openai.com/v1/");
}
public async Task<float[]> CreateEmbeddingAsync(string input, CancellationToken ct)
{
EnsureConfigured();
var payload = new { model = _settings.EmbeddingModel, input };
using var response = await _httpClient.PostAsync("embeddings", ToJson(payload), ct);
var json = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"OpenAI embeddings request failed: {(int)response.StatusCode} {json}");
}
using var document = JsonDocument.Parse(json);
var embedding = document.RootElement.GetProperty("data")[0].GetProperty("embedding");
var result = new float[embedding.GetArrayLength()];
var i = 0;
foreach (var value in embedding.EnumerateArray())
{
result[i++] = value.GetSingle();
}
return result;
}
public async Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, CancellationToken ct)
{
EnsureConfigured();
var payload = new
{
model = _settings.ChatModel,
temperature = 0.2,
response_format = new { type = "json_object" },
messages = new[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
};
using var response = await _httpClient.PostAsync("chat/completions", ToJson(payload), ct);
var json = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"OpenAI chat request failed: {(int)response.StatusCode} {json}");
}
using var document = JsonDocument.Parse(json);
return document.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString() ?? "{}";
}
private void EnsureConfigured()
{
if (string.IsNullOrWhiteSpace(_settings.ApiKey))
{
throw new InvalidOperationException("OpenAI API key is not configured. Set OpenAI__ApiKey.");
}
}
private static StringContent ToJson<T>(T payload) => new(
JsonSerializer.Serialize(payload, JsonOptions),
Encoding.UTF8,
"application/json"
);
}
+33
View File
@@ -0,0 +1,33 @@
using System.Text;
using UglyToad.PdfPig;
namespace Api.Services.Rag;
public interface IPdfTextExtractor
{
string ExtractText(Stream pdfStream);
}
public sealed class PdfTextExtractor : IPdfTextExtractor
{
public string ExtractText(Stream pdfStream)
{
using var document = PdfDocument.Open(pdfStream);
var builder = new StringBuilder();
foreach (var page in document.GetPages())
{
builder.AppendLine(page.Text);
builder.AppendLine();
}
return NormalizeWhitespace(builder.ToString());
}
private static string NormalizeWhitespace(string value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var parts = value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
return string.Join(' ', parts).Trim();
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace Api.Services.Rag;
public interface ITextChunker
{
IReadOnlyList<string> Chunk(string text, int chunkSize, int overlap);
}
public sealed class TextChunker : ITextChunker
{
public IReadOnlyList<string> Chunk(string text, int chunkSize, int overlap)
{
if (string.IsNullOrWhiteSpace(text)) return [];
chunkSize = Math.Clamp(chunkSize, 300, 3000);
overlap = Math.Clamp(overlap, 0, chunkSize / 2);
var chunks = new List<string>();
var start = 0;
while (start < text.Length)
{
var length = Math.Min(chunkSize, text.Length - start);
chunks.Add(text.Substring(start, length).Trim());
start += chunkSize - overlap;
}
return chunks.Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace Api.Settings;
public sealed class OpenAiSettings
{
public string ApiKey { get; set; } = string.Empty;
public string ChatModel { get; set; } = "gpt-4o-mini";
public string EmbeddingModel { get; set; } = "text-embedding-3-small";
public int TimeoutSeconds { get; set; } = 60;
}
+11
View File
@@ -0,0 +1,11 @@
namespace Api.Settings;
public sealed class RagSettings
{
public int MaxPdfSizeMb { get; set; } = 5;
public int ChunkSize { get; set; } = 900;
public int ChunkOverlap { get; set; } = 150;
public int CvTtlMinutes { get; set; } = 60;
public int MaxJobTextChars { get; set; } = 20000;
public int TopK { get; set; } = 6;
}
+2 -1
View File
@@ -18,9 +18,10 @@
<PackageReference Include="DotNetEnv" Version="3.2.0" />
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="PdfPig" Version="0.1.14" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
+25 -4
View File
@@ -1,6 +1,10 @@
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Email" ],
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.File",
"Serilog.Sinks.Email"
],
"MinimumLevel": {
"Default": "Information",
"Override": {
@@ -47,7 +51,11 @@
}
}
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithEnvironmentName" ]
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithEnvironmentName"
]
},
"Logging": {
"LogLevel": {
@@ -61,7 +69,6 @@
"LogEnvironmentOnStartup": true
},
"AllowedHosts": "*",
"KeyVault": {
"VaultUri": "",
"Enabled": false
@@ -98,5 +105,19 @@
"ToEmail": "",
"FromEmail": "",
"SubjectPrefix": "[File Download]"
},
"OpenAI": {
"ApiKey": "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",
"ChatModel": "gpt-4o-mini",
"EmbeddingModel": "text-embedding-3-small",
"TimeoutSeconds": 60
},
"Rag": {
"MaxPdfSizeMb": 5,
"ChunkSize": 900,
"ChunkOverlap": 150,
"CvTtlMinutes": 60,
"MaxJobTextChars": 20000,
"TopK": 6
}
}
}