diff --git a/api/.env.template b/api/.env.template index da80e88..c6112d0 100644 --- a/api/.env.template +++ b/api/.env.template @@ -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 diff --git a/api/Controllers/RagController.cs b/api/Controllers/RagController.cs new file mode 100644 index 0000000..04ca80c --- /dev/null +++ b/api/Controllers/RagController.cs @@ -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 _logger; + + public RagController(ICvRagService cvRagService, ILogger logger) + { + _cvRagService = cvRagService; + _logger = logger; + } + + [HttpPost("cv")] + [RequestSizeLimit(8 * 1024 * 1024)] + public async Task 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 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." }); + } + } +} diff --git a/api/Models/Rag/RagModels.cs b/api/Models/Rag/RagModels.cs new file mode 100644 index 0000000..9e4191e --- /dev/null +++ b/api/Models/Rag/RagModels.cs @@ -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 Strengths { get; set; } = []; + public List Gaps { get; set; } = []; + public List Recommendations { get; set; } = []; + public List 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; } +} diff --git a/api/Program.cs b/api/Program.cs index 00a42a0..63885e0 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -75,11 +75,19 @@ try builder.Services.Configure(builder.Configuration.GetSection("Smtp")); builder.Services.Configure(builder.Configuration.GetSection("Captcha")); builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); + builder.Services.Configure(builder.Configuration.GetSection("Rag")); + builder.Services.Configure(builder.Configuration.GetSection("OpenAI")); // Services builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddHttpClient(); + builder.Services.AddHttpClient(); // 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) => diff --git a/api/Services/Rag/CvRagService.cs b/api/Services/Rag/CvRagService.cs new file mode 100644 index 0000000..906cca7 --- /dev/null +++ b/api/Services/Rag/CvRagService.cs @@ -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 IngestCvAsync(IFormFile file, bool gdprConsent, CancellationToken ct); + Task 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 _logger; + + public CvRagService( + IPdfTextExtractor pdfTextExtractor, + ITextChunker textChunker, + IOpenAiRagClient openAi, + ICvVectorStore store, + IJobTextExtractor jobTextExtractor, + IOptions options, + ILogger logger) + { + _pdfTextExtractor = pdfTextExtractor; + _textChunker = textChunker; + _openAi = openAi; + _store = store; + _jobTextExtractor = jobTextExtractor; + _settings = options.Value; + _logger = logger; + } + + public async Task 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(); + 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 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 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(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 = [] + }; + } + } +} diff --git a/api/Services/Rag/InMemoryCvVectorStore.cs b/api/Services/Rag/InMemoryCvVectorStore.cs new file mode 100644 index 0000000..4cc3277 --- /dev/null +++ b/api/Services/Rag/InMemoryCvVectorStore.cs @@ -0,0 +1,79 @@ +using Api.Models.Rag; + +namespace Api.Services.Rag; + +public interface ICvVectorStore +{ + void Save(string documentId, IEnumerable chunks); + IReadOnlyList Get(string documentId); + IReadOnlyList Search(string documentId, float[] queryEmbedding, int topK); +} + +public sealed class InMemoryCvVectorStore : ICvVectorStore +{ + private readonly object _lock = new(); + private readonly Dictionary> _store = new(StringComparer.OrdinalIgnoreCase); + + public void Save(string documentId, IEnumerable chunks) + { + lock (_lock) + { + CleanupExpiredUnsafe(); + _store[documentId] = chunks.ToList(); + } + } + + public IReadOnlyList Get(string documentId) + { + lock (_lock) + { + CleanupExpiredUnsafe(); + return _store.TryGetValue(documentId, out var chunks) ? chunks.ToList() : []; + } + } + + public IReadOnlyList 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)); + } +} diff --git a/api/Services/Rag/JobTextExtractor.cs b/api/Services/Rag/JobTextExtractor.cs new file mode 100644 index 0000000..2454502 --- /dev/null +++ b/api/Services/Rag/JobTextExtractor.cs @@ -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 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 options) + { + _httpClient = httpClient; + _settings = options.Value; + _httpClient.Timeout = TimeSpan.FromSeconds(20); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0"); + } + + public async Task 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, "", " ", RegexOptions.IgnoreCase); + html = Regex.Replace(html, "", " ", 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(); + } +} diff --git a/api/Services/Rag/OpenAiRagClient.cs b/api/Services/Rag/OpenAiRagClient.cs new file mode 100644 index 0000000..a90015f --- /dev/null +++ b/api/Services/Rag/OpenAiRagClient.cs @@ -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 CreateEmbeddingAsync(string input, CancellationToken ct); + Task 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 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 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 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 payload) => new( + JsonSerializer.Serialize(payload, JsonOptions), + Encoding.UTF8, + "application/json" + ); +} diff --git a/api/Services/Rag/PdfTextExtractor.cs b/api/Services/Rag/PdfTextExtractor.cs new file mode 100644 index 0000000..68dd058 --- /dev/null +++ b/api/Services/Rag/PdfTextExtractor.cs @@ -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(); + } +} diff --git a/api/Services/Rag/TextChunker.cs b/api/Services/Rag/TextChunker.cs new file mode 100644 index 0000000..caf4dc4 --- /dev/null +++ b/api/Services/Rag/TextChunker.cs @@ -0,0 +1,27 @@ +namespace Api.Services.Rag; + +public interface ITextChunker +{ + IReadOnlyList Chunk(string text, int chunkSize, int overlap); +} + +public sealed class TextChunker : ITextChunker +{ + public IReadOnlyList 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(); + 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(); + } +} diff --git a/api/Settings/OpenAiSettings.cs b/api/Settings/OpenAiSettings.cs new file mode 100644 index 0000000..c6762b6 --- /dev/null +++ b/api/Settings/OpenAiSettings.cs @@ -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; +} diff --git a/api/Settings/RagSettings.cs b/api/Settings/RagSettings.cs new file mode 100644 index 0000000..405ecb8 --- /dev/null +++ b/api/Settings/RagSettings.cs @@ -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; +} diff --git a/api/api.csproj b/api/api.csproj index 384a4e3..4af1b72 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -18,9 +18,10 @@ + - + diff --git a/api/appsettings.json b/api/appsettings.json index 586ad9c..65d0d9a 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -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 } -} +} \ No newline at end of file