Add internet job search feature (cv-search-job)
Build and Push Docker Images / build (push) Failing after 1m36s
Build and Push Docker Images / build (push) Failing after 1m36s
- New cv-search-models shared library: EF entities + CvSearchDbContext for cvSearch schema (JobSearchTokens, JobSearchSessions, JobSearchResults tables) - New cv-search-job worker service: polls DB for pending sessions, scrapes job boards via configurable HTML scraping, runs LLM scoring via cv-matcher-api, emails ranked results - cv-matcher-api: JobTokenService creates one-time tokens; JobSearchController handles link clicks and creates sessions - api: proxies job-search start endpoint, appends job search link to match result email - CI workflow updated to build and push myai-cv-search-job:staging image - CLAUDE.md documentation added for all affected services Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ env:
|
||||
RAG_API_IMAGE: apps/myai-rag-api
|
||||
WEB_IMAGE: apps/myai-web
|
||||
CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job
|
||||
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
|
||||
IMAGE_TAG: staging
|
||||
|
||||
jobs:
|
||||
@@ -52,6 +53,10 @@ jobs:
|
||||
run: |
|
||||
docker build -f Jobs/cv-cleanup-job/Dockerfile -t "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" .
|
||||
|
||||
- name: Build CV search job image
|
||||
run: |
|
||||
docker build -f Jobs/cv-search-job/Dockerfile -t "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" .
|
||||
|
||||
- name: Push API image
|
||||
run: |
|
||||
docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}"
|
||||
@@ -70,4 +75,8 @@ jobs:
|
||||
|
||||
- name: Push CV cleanup job image
|
||||
run: |
|
||||
docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}"
|
||||
docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}"
|
||||
|
||||
- name: Push CV search job image
|
||||
run: |
|
||||
docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}"
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Models.Settings;
|
||||
|
||||
public sealed class JobSearchLinkSettings
|
||||
{
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# api — Public-Facing Proxy API
|
||||
|
||||
Internal port 8080. The only service exposed to the internet.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Validates reCAPTCHA on CV upload and match requests
|
||||
- Proxies CV operations to `cv-matcher-api` via Refit (`ICvMatcherApi`, `IJobSearchApi`)
|
||||
- Sends match result emails via SMTP (`SmtpEmailSender`)
|
||||
- Includes a job search link in match emails when a `CvDocumentId` is present
|
||||
- Serves the job-search-start page (`GET /api/cv-matcher/job-search/start?t=<token>`)
|
||||
- Enforces rate limiting (`cvMatcher` policy: 10 req / 10 min)
|
||||
- Enforces CORS (allow list from `Cors__AllowedOrigins__*` env vars)
|
||||
- Caches uploaded CV PDFs locally to `FileStorage:Path` for email attachment
|
||||
|
||||
## Key routes
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| POST | `/api/cv-matcher/upload` | Upload CV PDF, forward to cv-matcher-api |
|
||||
| POST | `/api/cv-matcher/match` | Match CV+job, send email with job search link |
|
||||
| GET | `/api/cv-matcher/job-search/start?t=<token>` | One-click job search start; returns plain HTML |
|
||||
| GET | `/api/health` | Health check |
|
||||
|
||||
## Job search link flow
|
||||
|
||||
1. After a successful match with an email, `CvMatcherController.MatchJob` calls `IJobSearchApi.CreateTokenAsync`
|
||||
2. Builds link: `{JobSearch:BaseUrl}/api/cv-matcher/job-search/start?t={tokenId}`
|
||||
3. Passes link to `SmtpEmailSender.BuildMatchEmailBody(result, jobSearchLink)`
|
||||
4. When user clicks link → `GET /api/cv-matcher/job-search/start?t=` → proxies to `cv-matcher-api POST /api/cv/job-search/token/{tokenId}/start`
|
||||
5. Returns styled HTML page (Started / AlreadyUsed / Expired / NotFound)
|
||||
|
||||
## Settings
|
||||
|
||||
| Section | Key env var | Notes |
|
||||
|---------|-------------|-------|
|
||||
| `CvMatcherApi` | `CvMatcherApi__BaseUrl`, `CvMatcherApi__InternalApiKey` | Shared by both Refit clients |
|
||||
| `JobSearch` | `JobSearch__BaseUrl` | Base URL for link generation only (maps to `JobSearchLinkSettings.BaseUrl`) |
|
||||
| `FileStorage` | `FileStorage__Path` | Directory for cached CV PDFs; shared volume with cv-search-job |
|
||||
| `Smtp` | `Smtp__Host`, `Smtp__Username`, etc. | Used by SmtpEmailSender |
|
||||
| `Captcha` | `Captcha__SecretKey` | reCAPTCHA v3 secret |
|
||||
|
||||
## HTML page generation
|
||||
|
||||
`CvMatcherController.HtmlPage(title, message)` uses `$$"""` raw string literal so CSS `{` / `}` are literal. Do not change to `$"""` — causes CS9006.
|
||||
@@ -0,0 +1,14 @@
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Refit;
|
||||
|
||||
namespace Api.Clients.Api.Contracts;
|
||||
|
||||
public interface IJobSearchApi
|
||||
{
|
||||
[Post("/api/cv/job-search/token")]
|
||||
Task<CreateJobSearchTokenResponse> CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct);
|
||||
|
||||
[Post("/api/cv/job-search/token/{tokenId}/start")]
|
||||
Task<StartJobSearchResponse> StartSearchAsync(string tokenId, CancellationToken ct);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Api.Clients.Api.Contracts;
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Models.Requests;
|
||||
using Models.Settings;
|
||||
using Api.Services.Contracts;
|
||||
@@ -20,21 +22,27 @@ namespace Api.Controllers;
|
||||
public sealed class CvMatcherController : ControllerBase
|
||||
{
|
||||
private readonly ICvMatcherApi _cvApi;
|
||||
private readonly IJobSearchApi _jobSearchApi;
|
||||
private readonly ICaptchaVerifier _captcha;
|
||||
private readonly FileStorageSettings _fileStorageSettings;
|
||||
private readonly JobSearchLinkSettings _jobSearchLinkSettings;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly ILogger<CvMatcherController> _logger;
|
||||
|
||||
public CvMatcherController(
|
||||
ICvMatcherApi cvApi,
|
||||
IJobSearchApi jobSearchApi,
|
||||
ICaptchaVerifier captcha,
|
||||
IOptions<FileStorageSettings> fileStorageSettings,
|
||||
IOptions<JobSearchLinkSettings> jobSearchLinkSettings,
|
||||
IEmailSender emailSender,
|
||||
ILogger<CvMatcherController> logger)
|
||||
{
|
||||
_cvApi = cvApi;
|
||||
_jobSearchApi = jobSearchApi;
|
||||
_captcha = captcha;
|
||||
_fileStorageSettings = fileStorageSettings.Value;
|
||||
_jobSearchLinkSettings = jobSearchLinkSettings.Value;
|
||||
_emailSender = emailSender;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -136,10 +144,27 @@ public sealed class CvMatcherController : ControllerBase
|
||||
? request.JobUrl
|
||||
: "Manual job description";
|
||||
|
||||
string? jobSearchLink = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenResp = await _jobSearchApi.CreateTokenAsync(
|
||||
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email },
|
||||
ct);
|
||||
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
|
||||
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not create job search token. Email link will be omitted.");
|
||||
}
|
||||
}
|
||||
|
||||
await _emailSender.SendMatchAsync(
|
||||
request.Email,
|
||||
SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel),
|
||||
SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel),
|
||||
SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, jobSearchLink),
|
||||
attachmentPath,
|
||||
ct);
|
||||
|
||||
@@ -157,6 +182,45 @@ public sealed class CvMatcherController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("job-search/start")]
|
||||
[SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a simple HTML confirmation page.")]
|
||||
public async Task<IActionResult> StartJobSearch([FromQuery] string t, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobSearchApi.StartSearchAsync(t, ct);
|
||||
var html = result.Status switch
|
||||
{
|
||||
StartJobSearchStatus.Started =>
|
||||
HtmlPage("Job search started", "Your job search has started. Results will be sent to your email shortly."),
|
||||
StartJobSearchStatus.AlreadyUsed =>
|
||||
HtmlPage("Link already used", "This job search link has already been used."),
|
||||
StartJobSearchStatus.Expired =>
|
||||
HtmlPage("Link expired", "This job search link has expired. Please request a new CV match to get a fresh link."),
|
||||
_ =>
|
||||
HtmlPage("Invalid link", "This job search link is not valid.")
|
||||
};
|
||||
return Content(html, "text/html");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Job search start failed for token {Token}.", t);
|
||||
return Content(HtmlPage("Error", "An error occurred. Please try again later."), "text/html");
|
||||
}
|
||||
}
|
||||
|
||||
private static string HtmlPage(string title, string message) => $$"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>{{title}} - MyAi.ro</title>
|
||||
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}
|
||||
.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}
|
||||
h1{font-size:1.4rem;margin-bottom:.5rem}p{color:#555}</style>
|
||||
</head>
|
||||
<body><div class="card"><h1>{{title}}</h1><p>{{message}}</p></div></body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
+16
-15
@@ -28,27 +28,28 @@ 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<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||
|
||||
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
|
||||
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
||||
|
||||
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
}
|
||||
static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client)
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
var key = config["CvMatcherApi:InternalApiKey"];
|
||||
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
}
|
||||
|
||||
var key = config["CvMatcherApi:InternalApiKey"];
|
||||
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
}
|
||||
});
|
||||
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
|
||||
.ConfigureHttpClient(ConfigureCvMatcherApiClient);
|
||||
|
||||
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.IJobSearchApi>()
|
||||
.ConfigureHttpClient(ConfigureCvMatcherApiClient);
|
||||
|
||||
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "API");
|
||||
builder.Services.ConfigureCaddyForwardedHeaders();
|
||||
|
||||
@@ -214,7 +214,9 @@ namespace Api.Services
|
||||
}
|
||||
}
|
||||
|
||||
public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel) => $@"CV Matcher result
|
||||
public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string? jobSearchLink = null)
|
||||
{
|
||||
var body = $@"CV Matcher result
|
||||
|
||||
CV Document ID: {cvDocumentId}
|
||||
Job: {jobLabel ?? "N/A"}
|
||||
@@ -233,6 +235,19 @@ Gaps:
|
||||
Recommendations:
|
||||
- {string.Join("\n- ", result.Recommendations)}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
||||
{
|
||||
body += $@"
|
||||
|
||||
---
|
||||
Vrei sa gasesti mai multe joburi potrivite CV-ului tau?
|
||||
Click: {jobSearchLink}
|
||||
(link valabil 7 zile)";
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
public static string BuildMatchEmailSubject(int score, string? jobLabel)
|
||||
=> $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace CvMatcher.Models.Requests;
|
||||
|
||||
public sealed class CreateJobSearchTokenRequest
|
||||
{
|
||||
public string CvDocumentId { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace CvMatcher.Models.Responses;
|
||||
|
||||
public sealed class CreateJobSearchTokenResponse
|
||||
{
|
||||
public string TokenId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace CvMatcher.Models.Responses;
|
||||
|
||||
public sealed class StartJobSearchResponse
|
||||
{
|
||||
public string Status { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class StartJobSearchStatus
|
||||
{
|
||||
public const string Started = "Started";
|
||||
public const string AlreadyUsed = "AlreadyUsed";
|
||||
public const string Expired = "Expired";
|
||||
public const string NotFound = "NotFound";
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# cv-matcher-api — Internal CV Match Engine
|
||||
|
||||
Internal port 8082. Only reachable from `api` and `cv-search-job` via `X-Internal-Api-Key`.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Indexes CV PDFs into the RAG system via `rag-api`
|
||||
- Matches a CV against a job posting URL (scrapes job HTML, scores pair with LLM)
|
||||
- Manages job search tokens and sessions for the one-click job search feature
|
||||
- Owns two EF DbContexts: `CvMatcherDbContext` (schema `cvMatcher`) and `CvSearchDbContext` (schema `cvSearch`)
|
||||
- Runs EF migrations for both contexts on startup
|
||||
|
||||
## Key routes
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| POST | `/api/cv/upload` | Index CV PDF into RAG |
|
||||
| POST | `/api/cv/match-job` | Score CV against a job URL (LLM call) |
|
||||
| POST | `/api/cv/find-jobs` | Find matching jobs from the RAG index |
|
||||
| POST | `/api/cv/job-search/token` | Create a job search token (called by api after a match) |
|
||||
| POST | `/api/cv/job-search/token/{tokenId}/start` | Validate token, create Pending session (called by api on link click) |
|
||||
| GET | `/api/health` | Health check |
|
||||
|
||||
## Core services
|
||||
|
||||
- `CvMatcherService` — orchestrates upload + match; calls `IRagApiClient` and `IMatcherAiClient`
|
||||
- `JobTextExtractor` — fetches a job page URL and extracts plain text
|
||||
- `JobTokenService` — creates tokens; validates + starts job search sessions; extracts CV keywords using simple heuristics (first 5 meaningful non-empty lines of CV text, split into words)
|
||||
|
||||
## AI providers
|
||||
|
||||
Configured under `Ai:Provider` (`OpenAI` or `Ollama`). Both providers implement `IMatcherAiClient`.
|
||||
Default model: `gpt-4o-mini`. Timeout: 90 s.
|
||||
|
||||
## Database contexts
|
||||
|
||||
Both contexts use the same SQL Server connection string (from `Database:*` settings).
|
||||
|
||||
- `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-api` assembly
|
||||
- `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-models` assembly (MigrationsAssembly = "cv-search-models")
|
||||
|
||||
## Keyword extraction (JobTokenService.ExtractKeywords)
|
||||
|
||||
No LLM call. Takes the first 5 non-empty lines of CV text that are:
|
||||
- Longer than 5 characters
|
||||
- Not purely numeric or contact-line patterns
|
||||
|
||||
Splits into words, strips punctuation, deduplicates, returns up to 10 comma-separated keywords.
|
||||
These keywords are stored in `JobSearchSessionEntity.Keywords` and used by `cv-search-job` for scraping.
|
||||
|
||||
## Settings
|
||||
|
||||
| Section | Notes |
|
||||
|---------|-------|
|
||||
| `Database` | Shared SQL Server connection |
|
||||
| `RagApi` | BaseUrl + InternalApiKey for rag-api |
|
||||
| `Ai` | Provider, model, timeout |
|
||||
| `Matcher` | TopK, DeepScoreTopN, MaxJobTextChars |
|
||||
| `JobSearch` | TokenExpiryDays, providers list (stored in session JSON) |
|
||||
| `InternalApi` | ApiKey used by UseInternalApiKeyProtection middleware |
|
||||
@@ -0,0 +1,56 @@
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shared.Models.Responses;
|
||||
|
||||
namespace Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/cv/job-search")]
|
||||
public sealed class JobSearchController : ControllerBase
|
||||
{
|
||||
private readonly IJobTokenService _tokenService;
|
||||
private readonly ILogger<JobSearchController> _logger;
|
||||
|
||||
public JobSearchController(IJobTokenService tokenService, ILogger<JobSearchController> logger)
|
||||
{
|
||||
_tokenService = tokenService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("token")]
|
||||
public async Task<ActionResult<CreateJobSearchTokenResponse>> CreateToken(
|
||||
[FromBody] CreateJobSearchTokenRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email))
|
||||
return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" });
|
||||
|
||||
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, ct);
|
||||
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create job search token.");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed to create token.", Code = "token_create_failed" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("token/{tokenId}/start")]
|
||||
public async Task<ActionResult<StartJobSearchResponse>> Start(string tokenId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = await _tokenService.TriggerStartAsync(tokenId, ct);
|
||||
return Ok(new StartJobSearchResponse { Status = status });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to start job search for token {TokenId}.", tokenId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed to start search.", Code = "start_failed" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ using Api.Data.Repositories.Contracts;
|
||||
using Api.Services;
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Settings;
|
||||
using CvSearch.Models.Data;
|
||||
using CvSearch.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
@@ -34,6 +36,7 @@ try
|
||||
builder.Services.Configure<InternalApiSettings>(builder.Configuration.GetSection("InternalApi"));
|
||||
builder.Services.Configure<CvMatcher.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai"));
|
||||
builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher"));
|
||||
builder.Services.Configure<JobSearchSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||
|
||||
builder.Services.AddRefitClient<IRefitRagApi>()
|
||||
.ConfigureHttpClient((sp, c) =>
|
||||
@@ -61,8 +64,19 @@ try
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddDbContext<CvSearchDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsAssembly("cv-search-models");
|
||||
sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName);
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<IMatcherRepository, EfMatcherRepository>();
|
||||
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
|
||||
builder.Services.AddScoped<IJobTokenService, JobTokenService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName);
|
||||
@@ -90,6 +104,11 @@ try
|
||||
var db = scope.ServiceProvider.GetRequiredService<CvMatcherDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("{Service} startup complete", ServiceName);
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
public interface IJobTokenService
|
||||
{
|
||||
Task<string> CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct);
|
||||
Task<string> TriggerStartAsync(string tokenId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Api.Clients.Api.Contracts;
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvSearch.Models.Data;
|
||||
using CvSearch.Models.Data.Entities;
|
||||
using CvSearch.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
public sealed class JobTokenService : IJobTokenService
|
||||
{
|
||||
private readonly CvSearchDbContext _db;
|
||||
private readonly IRagApiClient _rag;
|
||||
private readonly JobSearchSettings _settings;
|
||||
private readonly ILogger<JobTokenService> _logger;
|
||||
|
||||
public JobTokenService(
|
||||
CvSearchDbContext db,
|
||||
IRagApiClient rag,
|
||||
IOptions<JobSearchSettings> settings,
|
||||
ILogger<JobTokenService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_rag = rag;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct)
|
||||
{
|
||||
var token = new JobSearchTokenEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
CvDocumentId = cvDocumentId,
|
||||
Email = email,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays),
|
||||
Used = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.JobSearchTokens.Add(token);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}", token.Id, cvDocumentId);
|
||||
return token.Id;
|
||||
}
|
||||
|
||||
public async Task<string> TriggerStartAsync(string tokenId, CancellationToken ct)
|
||||
{
|
||||
var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct);
|
||||
if (token is null) return StartJobSearchStatus.NotFound;
|
||||
if (token.Used) return StartJobSearchStatus.AlreadyUsed;
|
||||
if (token.ExpiresAt <= DateTime.UtcNow) return StartJobSearchStatus.Expired;
|
||||
|
||||
token.Used = true;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct);
|
||||
var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty;
|
||||
|
||||
var providerConfigJson = JsonSerializer.Serialize(
|
||||
_settings.Providers.Where(p => p.Enabled).ToList(),
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
var session = new JobSearchSessionEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
TokenId = token.Id,
|
||||
CvDocumentId = token.CvDocumentId,
|
||||
Email = token.Email,
|
||||
Status = JobSearchStatus.Pending,
|
||||
Keywords = keywords,
|
||||
ProviderConfigJson = providerConfigJson,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.JobSearchSessions.Add(session);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
_logger.LogInformation("Job search session created. SessionId={SessionId}, Keywords={Keywords}", session.Id, keywords);
|
||||
|
||||
return StartJobSearchStatus.Started;
|
||||
}
|
||||
|
||||
private static string ExtractKeywords(string cvText)
|
||||
{
|
||||
var lines = cvText
|
||||
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 5 && l.Length < 200)
|
||||
.Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$"))
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
var words = lines
|
||||
.SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(w => Regex.Replace(w, @"[^\w\-]", ""))
|
||||
.Where(w => w.Length > 2)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
return string.Join(",", words);
|
||||
}
|
||||
}
|
||||
@@ -106,5 +106,38 @@
|
||||
"TopK": 10,
|
||||
"DeepScoreTopN": 5,
|
||||
"MaxJobTextChars": 60000
|
||||
},
|
||||
"JobSearch": {
|
||||
"Enabled": true,
|
||||
"JobSearchLinkBaseUrl": "https://myai.ro",
|
||||
"TokenExpiryDays": 7,
|
||||
"MinMatchScore": 15,
|
||||
"MaxJobsToMatch": 15,
|
||||
"Providers": [
|
||||
{
|
||||
"Name": "ejobs.ro",
|
||||
"Enabled": false,
|
||||
"SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/",
|
||||
"JobLinkContains": "/user/locuri-de-munca/job/",
|
||||
"InitialKeywords": [],
|
||||
"MaxResults": 20
|
||||
},
|
||||
{
|
||||
"Name": "bestjobs.eu",
|
||||
"Enabled": false,
|
||||
"SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}",
|
||||
"JobLinkContains": "/ro/locuri-de-munca/",
|
||||
"InitialKeywords": [],
|
||||
"MaxResults": 20
|
||||
},
|
||||
{
|
||||
"Name": "linkedin.com",
|
||||
"Enabled": false,
|
||||
"SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania",
|
||||
"JobLinkContains": "/jobs/view/",
|
||||
"InitialKeywords": [],
|
||||
"MaxResults": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
|
||||
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
||||
<ProjectReference Include="..\cv-search-models\cv-search-models.csproj" />
|
||||
<ProjectReference Include="..\shared-models\shared-models.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using CvSearch.Models.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CvSearch.Models.Data;
|
||||
|
||||
public sealed class CvSearchDbContext : DbContext
|
||||
{
|
||||
public const string SchemaName = "cvSearch";
|
||||
public const string MigrationTableName = "_Migrations";
|
||||
|
||||
public CvSearchDbContext(DbContextOptions<CvSearchDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>();
|
||||
public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>();
|
||||
public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder.Entity<JobSearchTokenEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("JobSearchTokens");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).HasMaxLength(64);
|
||||
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
||||
entity.Property(x => x.Used).HasDefaultValue(false);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<JobSearchSessionEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("JobSearchSessions");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).HasMaxLength(64);
|
||||
entity.Property(x => x.TokenId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
||||
entity.Property(x => x.Status).HasMaxLength(32).IsRequired();
|
||||
entity.Property(x => x.Keywords).HasMaxLength(1000);
|
||||
entity.Property(x => x.ProviderConfigJson).IsRequired(false);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
entity.HasIndex(x => x.Status);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<JobSearchResultEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("JobSearchResults");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).HasMaxLength(64);
|
||||
entity.Property(x => x.SessionId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.ProviderName).HasMaxLength(128);
|
||||
entity.Property(x => x.JobUrl).HasMaxLength(2048);
|
||||
entity.Property(x => x.JobTitle).HasMaxLength(512);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
entity.HasIndex(x => x.SessionId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace CvSearch.Models.Data.Entities;
|
||||
|
||||
public sealed class JobSearchResultEntity
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
public string ProviderName { get; set; } = string.Empty;
|
||||
public string JobUrl { get; set; } = string.Empty;
|
||||
public string JobTitle { get; set; } = string.Empty;
|
||||
public string JobText { get; set; } = string.Empty;
|
||||
public int Score { get; set; }
|
||||
public string ResultJson { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace CvSearch.Models.Data.Entities;
|
||||
|
||||
public sealed class JobSearchSessionEntity
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string TokenId { get; set; } = string.Empty;
|
||||
public string CvDocumentId { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = JobSearchStatus.Pending;
|
||||
public string Keywords { get; set; } = string.Empty;
|
||||
public string? ProviderConfigJson { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static class JobSearchStatus
|
||||
{
|
||||
public const string Pending = "Pending";
|
||||
public const string Processing = "Processing";
|
||||
public const string Done = "Done";
|
||||
public const string Failed = "Failed";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace CvSearch.Models.Data.Entities;
|
||||
|
||||
public sealed class JobSearchTokenEntity
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string CvDocumentId { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public bool Used { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvSearch.Models.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Models.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvSearchDbContext))]
|
||||
[Migration("20260522093356_AddJobSearchTables")]
|
||||
partial class AddJobSearchTables
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("cvSearch")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("JobText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("JobUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("nvarchar(2048)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SessionId");
|
||||
|
||||
b.ToTable("JobSearchResults", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("ProviderConfigJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("TokenId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("JobSearchSessions", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobSearchTokens", "cvSearch");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Models.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobSearchTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "cvSearch");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchResults",
|
||||
schema: "cvSearch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
SessionId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ProviderName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
JobUrl = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
|
||||
JobTitle = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
|
||||
JobText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Score = table.Column<int>(type: "int", nullable: false),
|
||||
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JobSearchResults", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchSessions",
|
||||
schema: "cvSearch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
TokenId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Keywords = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||
ProviderConfigJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JobSearchSessions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchTokens",
|
||||
schema: "cvSearch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Used = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JobSearchTokens", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobSearchResults_SessionId",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchResults",
|
||||
column: "SessionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobSearchSessions_Status",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchSessions",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchResults",
|
||||
schema: "cvSearch");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchSessions",
|
||||
schema: "cvSearch");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchTokens",
|
||||
schema: "cvSearch");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvSearch.Models.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Models.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvSearchDbContext))]
|
||||
partial class CvSearchDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("cvSearch")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("JobText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("JobUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("nvarchar(2048)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SessionId");
|
||||
|
||||
b.ToTable("JobSearchResults", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("ProviderConfigJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("TokenId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("JobSearchSessions", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobSearchTokens", "cvSearch");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace CvSearch.Models.Settings;
|
||||
|
||||
public sealed class JobSearchSettings
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string JobSearchLinkBaseUrl { get; set; } = string.Empty;
|
||||
public int TokenExpiryDays { get; set; } = 7;
|
||||
public int MinMatchScore { get; set; } = 15;
|
||||
public int MaxJobsToMatch { get; set; } = 15;
|
||||
public List<JobProviderConfig> Providers { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class JobProviderConfig
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string SearchUrlTemplate { get; set; } = string.Empty;
|
||||
public string JobLinkContains { get; set; } = string.Empty;
|
||||
public List<string> InitialKeywords { get; set; } = [];
|
||||
public int MaxResults { get; set; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>CvSearch.Models</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,158 @@
|
||||
# myAi — Solution Guide
|
||||
|
||||
## Infrastructure URLs
|
||||
|
||||
| Purpose | URL |
|
||||
|---------|-----|
|
||||
| Staging app | https://myai.easysoft.ro |
|
||||
| Production app | https://myai.ro |
|
||||
| Portainer (container management) | https://portainer.easysoft.ro/#!/auth |
|
||||
| Grafana (logs) | https://grafana.easysoft.ro/login |
|
||||
| Gitea (source control) | https://git.easysoft.ro |
|
||||
|
||||
The Gitea instance has two deployment repos:
|
||||
- **staging repo** → auto-deploys to `myai.easysoft.ro`
|
||||
- **production repo** → auto-deploys to `myai.ro`
|
||||
|
||||
## Staging browser testing
|
||||
|
||||
To verify a feature against staging use the `verify` skill pointed at `https://myai.easysoft.ro`.
|
||||
Portainer at `portainer.easysoft.ro` can restart containers or inspect live state.
|
||||
Grafana at `grafana.easysoft.ro` shows structured logs from all containers.
|
||||
|
||||
## Feature workflow (plan → ship)
|
||||
|
||||
When a plan is approved and implementation begins:
|
||||
1. Add the plan as a **Gitea Wiki page** in the relevant repository (under a `Features/` or `Plans/` namespace)
|
||||
2. Create **Gitea Issues** — one per logical work chunk — and link them to the Wiki page
|
||||
3. Reference the issue number in commit messages (`Closes #N`)
|
||||
4. Issues are closed automatically (or manually) when the code is merged
|
||||
|
||||
This applies to both the staging and production repos as appropriate.
|
||||
|
||||
## Tech stack
|
||||
- .NET 10, ASP.NET Core, Worker Service
|
||||
- Entity Framework Core + SQL Server (multi-schema)
|
||||
- Refit for typed HTTP clients between services
|
||||
- Serilog (JSON structured logging, Console + File + Email sinks)
|
||||
- MailKit for SMTP
|
||||
- Docker Compose for local and production deployment
|
||||
- Watchtower for automatic container updates in production
|
||||
|
||||
## Solution layout
|
||||
|
||||
```
|
||||
Apis/
|
||||
api/ Public-facing proxy API (port 8080). Handles CORS, rate limiting, captcha, email.
|
||||
api-models/ DTOs and settings shared by api only.
|
||||
cv-matcher-api/ Internal CV match engine (port 8082). Owns cvMatcher + cvSearch DB schemas.
|
||||
cv-matcher-api-models/ DTOs shared between api and cv-matcher-api.
|
||||
cv-search-models/ EF entities + DbContext for cvSearch schema. Shared by cv-matcher-api and cv-search-job.
|
||||
rag-api/ Internal RAG/vector-search service (port 8081). Owns rag DB schema.
|
||||
rag-api-models/ DTOs shared with rag-api.
|
||||
shared-models/ Cross-service shared models (DatabaseSettings, etc.).
|
||||
Helpers/
|
||||
startup-helpers/ Shared Program.cs bootstrap: Serilog, Swagger, .env loading, Azure Key Vault, middleware.
|
||||
common-helpers/ Utility helpers.
|
||||
Jobs/
|
||||
job-scheduler/ IJobTask + JobSchedulerHostedService — the reusable scheduled-job engine.
|
||||
cv-cleanup-job/ Worker: deletes old CVs from file storage. Runs hourly.
|
||||
cv-search-job/ Worker: picks up pending job search sessions, scrapes providers, emails results.
|
||||
web/ Razor Pages / Blazor front-end (port 5000).
|
||||
docker-compose/ docker-compose.yml + .env file.
|
||||
```
|
||||
|
||||
## Build & restore
|
||||
|
||||
```powershell
|
||||
dotnet restore myAi.sln
|
||||
dotnet build myAi.sln
|
||||
```
|
||||
|
||||
## Running locally with Docker
|
||||
|
||||
```powershell
|
||||
docker compose -f docker-compose/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
Config lives in `docker-compose/.env`. All env vars use `${VAR:-default}` fallback syntax.
|
||||
|
||||
## Database schemas
|
||||
|
||||
| Schema | Owner DbContext | Migrations assembly |
|
||||
|-------------|----------------------|-----------------------|
|
||||
| `cvMatcher` | `CvMatcherDbContext` | `cv-matcher-api` |
|
||||
| `rag` | `RagDbContext` | `rag-api` |
|
||||
| `cvSearch` | `CvSearchDbContext` | `cv-search-models` |
|
||||
|
||||
Both `cv-matcher-api` and `cv-search-job` register `CvSearchDbContext` and call `db.Database.Migrate()` on startup (idempotent — safe for both to run).
|
||||
|
||||
## EF Core migrations
|
||||
|
||||
```powershell
|
||||
# Add a migration to cv-search-models
|
||||
dotnet ef migrations add <MigrationName> \
|
||||
--context CvSearchDbContext \
|
||||
--project Apis/cv-search-models \
|
||||
--startup-project Apis/cv-matcher-api
|
||||
|
||||
# Add a migration to cv-matcher-api
|
||||
dotnet ef migrations add <MigrationName> \
|
||||
--context CvMatcherDbContext \
|
||||
--project Apis/cv-matcher-api
|
||||
```
|
||||
|
||||
EF tools version warning ("older than runtime") is expected and harmless. The `HostAbortedException` output during migration scaffolding is normal — EF starts the host to discover DbContext then aborts it.
|
||||
|
||||
## Service dependency chain
|
||||
|
||||
```
|
||||
web → api → cv-matcher-api → rag-api
|
||||
↑
|
||||
cv-search-job
|
||||
```
|
||||
|
||||
`api` never talks directly to `rag-api` — always via `cv-matcher-api`.
|
||||
|
||||
## Internal API key auth
|
||||
|
||||
All internal service-to-service calls require the `X-Internal-Api-Key` header.
|
||||
The key is shared via the `CvMatcherApi__InternalApiKey` and `RagApi__InternalApiKey` env vars.
|
||||
`startup-helpers` provides `UseInternalApiKeyProtection()` middleware that enforces it on `cv-matcher-api` and `rag-api`.
|
||||
|
||||
## Shared file storage
|
||||
|
||||
CV PDFs are written by `api` to `Apis/api/Files/` and read by `cv-cleanup-job` and `cv-search-job`.
|
||||
All three containers mount the same bind volume:
|
||||
```yaml
|
||||
- ../Apis/api/Files:/app/Files
|
||||
```
|
||||
The path inside containers is controlled by `FileStorage__Path` (default: `Files`).
|
||||
|
||||
## Job task pattern
|
||||
|
||||
Every background worker uses the same pattern from `job-scheduler`:
|
||||
1. Implement `IJobTask` (has `TaskType` string + `ExecuteAsync(CancellationToken)`)
|
||||
2. Register as singleton: `services.AddSingleton<IEnumerable<IJobTask>>(sp => new IJobTask[] { ... })`
|
||||
3. Register `JobSchedulerHostedService` as hosted service
|
||||
4. Configure in appsettings under `Jobs:Tasks` array: `TaskType`, `Enabled`, `Interval`
|
||||
|
||||
## Program.cs conventions
|
||||
|
||||
Every service follows this structure:
|
||||
1. `StartupExtensions.LoadDotEnvFile()` — must be first, loads `docker-compose/.env`
|
||||
2. `StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly())`
|
||||
3. `builder.ConfigureJsonSerilog(ServiceName, appVersion)` from startup-helpers
|
||||
4. `builder.AddAzureKeyVaultIfConfigured()` (APIs only)
|
||||
5. `app.UseDefaultSerilogRequestLogging()`
|
||||
6. `app.UseJsonExceptionHandler(ServiceName)`
|
||||
7. EF migrations in a scoped block before `app.Run()`
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- No XML doc comments on internal code; Swagger annotations on public controller actions
|
||||
- No explanatory inline comments — code should be self-describing
|
||||
- Use `$$"""..."""` raw string literals (not `$"""`) when the content contains CSS or other curly-brace-heavy text — avoids CS9006 brace-escaping errors
|
||||
- `sealed` on all concrete service classes
|
||||
- Settings classes injected via `IOptions<T>` — registered with `Configure<T>(config.GetSection("..."))`
|
||||
- Refit clients configured via a shared local function when multiple clients share the same base URL and auth header (see `api/Program.cs` → `ConfigureCvMatcherApiClient`)
|
||||
@@ -0,0 +1,90 @@
|
||||
# cv-search-job — Internet Job Search Worker
|
||||
|
||||
Background worker. Polls the database every 30 s for pending job search sessions and processes them.
|
||||
|
||||
## What it does (per session)
|
||||
|
||||
1. Reads session from DB (`Status = Pending`)
|
||||
2. Sets `Status = Processing`
|
||||
3. Deserializes `ProviderConfigJson` (snapshot of provider configs taken at token-start time)
|
||||
4. For each enabled provider: calls `HtmlJobSearcher` to scrape job URLs
|
||||
5. Deduplicates URLs across providers, caps at `MaxJobsToMatch` (default 15)
|
||||
6. Calls `cv-matcher-api POST /api/cv/match-job` for each URL (uses existing LLM scoring)
|
||||
7. Saves each result as `JobSearchResultEntity`
|
||||
8. Filters to `Score >= MinMatchScore` (default 15)
|
||||
9. Sets `Status = Done`, saves keywords + provider snapshot to session
|
||||
10. Sends ranked results email via `CvSearchEmailSender` (dual-recipient: user + `Contact:ToEmail`)
|
||||
11. Attaches CV PDF from shared file storage if it exists
|
||||
|
||||
## Crash recovery
|
||||
|
||||
On every tick, sessions with `Status = Processing` AND `CreatedAt < UtcNow - 10 min` are reset to `Pending`. This handles container restarts mid-processing.
|
||||
|
||||
## HtmlJobSearcher — generic HTML scraper
|
||||
|
||||
No per-provider logic. Config-driven. For each provider:
|
||||
1. Combines `provider.InitialKeywords` + CV keywords from session, URL-encodes as space-joined string
|
||||
2. `GET {SearchUrlTemplate}` with keyword substitution
|
||||
3. Regex-parses all `<a href="..." >text</a>` tags
|
||||
4. Two-stage filter:
|
||||
- Stage 1: `href` must contain `JobLinkContains`
|
||||
- Stage 2: anchor text must contain at least one CV keyword
|
||||
5. Makes hrefs absolute, deduplicates, returns up to `MaxResults` URLs
|
||||
|
||||
## Provider config
|
||||
|
||||
Defined under `JobSearch:Providers` in appsettings / docker-compose env vars. Three providers ship as defaults (all `Enabled: false`):
|
||||
|
||||
| Name | Notes |
|
||||
|------|-------|
|
||||
| `ejobs.ro` | Romanian job board; reliable HTML structure |
|
||||
| `bestjobs.eu` | Romanian job board |
|
||||
| `linkedin.com` | Likely to return empty results due to bot detection |
|
||||
|
||||
Provider config is snapshotted to `JobSearchSessionEntity.ProviderConfigJson` at session creation time (in `cv-matcher-api`), so changes to config do not affect in-flight sessions.
|
||||
|
||||
To enable a provider via docker-compose env var (index-based):
|
||||
```
|
||||
JobSearch__Providers__0__Enabled=true # ejobs.ro
|
||||
JobSearch__Providers__1__Enabled=true # bestjobs.eu
|
||||
JobSearch__Providers__2__Enabled=true # linkedin.com
|
||||
```
|
||||
|
||||
## Email
|
||||
|
||||
`CvSearchEmailSender` reads SMTP config directly from `IConfiguration` (same `Smtp:*` keys as `api`).
|
||||
Sends to both `toEmail` (from session) and `Contact:ToEmail` (operator copy).
|
||||
CV PDF attached from `{FileStorage:Path}/{cvDocumentId}.pdf` if the file exists.
|
||||
|
||||
## Shared volume
|
||||
|
||||
`../Apis/api/Files:/app/Files` — same bind mount as `api` and `cv-cleanup-job`.
|
||||
CV PDFs written by `api` are readable here without any API call.
|
||||
|
||||
## Key settings
|
||||
|
||||
| Section | Env var | Notes |
|
||||
|---------|---------|-------|
|
||||
| `Database` | `Database__*` | Same SQL Server as other services |
|
||||
| `CvMatcherApi` | `CvMatcherApi__BaseUrl`, `CvMatcherApi__InternalApiKey` | Internal call to match-job endpoint |
|
||||
| `Smtp` | `Smtp__*` | Same vars as `api` |
|
||||
| `Contact` | `Contact__ToEmail` | Operator copy recipient |
|
||||
| `FileStorage` | `FileStorage__Path` | Must match the shared volume mount path |
|
||||
| `JobSearch` | `JobSearch__Enabled`, `MinMatchScore`, `MaxJobsToMatch` | Core search limits |
|
||||
| `Jobs:Tasks:0` | `Jobs__Tasks__0__Interval` | Poll interval (default `00:00:30`) |
|
||||
|
||||
## Logging
|
||||
|
||||
Follows the same scheme as `cv-cleanup-job`:
|
||||
- **Console** — `[HH:mm:ss LVL] SourceContext: Message`
|
||||
- **File** — `logs/cv-search-job-.log`, daily rolling, 30-day retention
|
||||
- **Email** (index 2) — Errors only, wired via `Serilog__WriteTo__2__Args__*` env vars in docker-compose
|
||||
- **Enrich** — `FromLogContext`, `WithMachineName`, `WithEnvironmentName`
|
||||
|
||||
`Serilog.Sinks.Email` is available transitively through `startup-helpers` — no extra package needed in the csproj.
|
||||
|
||||
## EF migrations
|
||||
|
||||
This project runs `CvSearchDbContext.Database.Migrate()` on startup.
|
||||
Migrations live in `Apis/cv-search-models/Migrations/`.
|
||||
To add a migration: see root CLAUDE.md.
|
||||
@@ -0,0 +1,11 @@
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Refit;
|
||||
|
||||
namespace CvSearchJob.Clients;
|
||||
|
||||
public interface ICvMatcherInternalApi
|
||||
{
|
||||
[Post("/api/cv/match-job")]
|
||||
Task<JobMatchResponse> MatchJobAsync([Body] MatchJobRequest request, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
|
||||
COPY Jobs/cv-search-job/cv-search-job.csproj Jobs/cv-search-job/
|
||||
COPY Jobs/job-scheduler/job-scheduler.csproj Jobs/job-scheduler/
|
||||
COPY Apis/cv-search-models/cv-search-models.csproj Apis/cv-search-models/
|
||||
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
|
||||
COPY Apis/shared-models/shared-models.csproj Apis/shared-models/
|
||||
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
|
||||
|
||||
RUN dotnet restore Jobs/cv-search-job/cv-search-job.csproj
|
||||
|
||||
COPY Jobs/cv-search-job/ Jobs/cv-search-job/
|
||||
COPY Jobs/job-scheduler/ Jobs/job-scheduler/
|
||||
COPY Apis/cv-search-models/ Apis/cv-search-models/
|
||||
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
|
||||
COPY Apis/shared-models/ Apis/shared-models/
|
||||
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
|
||||
|
||||
RUN dotnet publish Jobs/cv-search-job/cv-search-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
ENTRYPOINT ["dotnet", "cv-search-job.dll"]
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Reflection;
|
||||
using CvSearch.Models.Data;
|
||||
using CvSearch.Models.Settings;
|
||||
using CvSearchJob.Clients;
|
||||
using CvSearchJob.Services;
|
||||
using CvSearchJob.Tasks;
|
||||
using JobScheduler.Scheduling;
|
||||
using JobScheduler.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
using Shared.Models.Settings;
|
||||
using StartupHelpers;
|
||||
|
||||
const string ServiceName = "cv-search-job";
|
||||
|
||||
StartupExtensions.LoadDotEnvFile();
|
||||
var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly());
|
||||
|
||||
try
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.ConfigureJsonSerilog(ServiceName, appVersion);
|
||||
Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion);
|
||||
|
||||
builder.Services.Configure<JobSearchSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||
builder.Services.Configure<DatabaseSettings>(builder.Configuration.GetSection("Database"));
|
||||
|
||||
builder.Services.AddDbContext<CvSearchDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsAssembly("cv-search-models");
|
||||
sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName);
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddRefitClient<ICvMatcherInternalApi>()
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var config = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
|
||||
var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
var key = config["CvMatcherApi:InternalApiKey"];
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<HtmlJobSearcher>();
|
||||
builder.Services.AddSingleton<CvSearchEmailSender>();
|
||||
|
||||
builder.Services.AddSingleton<CvSearchJobTask>();
|
||||
builder.Services.AddSingleton<IEnumerable<IJobTask>>(sp => new IJobTask[]
|
||||
{
|
||||
sp.GetRequiredService<CvSearchJobTask>(),
|
||||
});
|
||||
|
||||
builder.Services.AddHostedService<JobSchedulerHostedService>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
host.LogHostStartupDiagnostics(ServiceName);
|
||||
|
||||
Log.Information("Running EF Core migrations for CvSearchDbContext");
|
||||
using (var scope = host.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("{Service} startup complete. Background scheduler is running.", ServiceName);
|
||||
await host.RunAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvSearch.Models.Data.Entities;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MimeKit;
|
||||
|
||||
namespace CvSearchJob.Services;
|
||||
|
||||
public sealed class CvSearchEmailSender
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<CvSearchEmailSender> _logger;
|
||||
|
||||
public CvSearchEmailSender(IConfiguration config, ILogger<CvSearchEmailSender> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendResultsAsync(
|
||||
string toEmail,
|
||||
string? attachmentPath,
|
||||
IReadOnlyList<JobSearchResultEntity> results,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var smtpHost = _config["Smtp:Host"];
|
||||
var smtpPort = int.TryParse(_config["Smtp:Port"], out var port) ? port : 587;
|
||||
var smtpUser = _config["Smtp:Username"];
|
||||
var smtpPass = _config["Smtp:Password"];
|
||||
var useStartTls = bool.TryParse(_config["Smtp:UseStartTls"], out var tls) && tls;
|
||||
var contactToEmail = _config["Contact:ToEmail"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(smtpHost)) return;
|
||||
|
||||
var recipients = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail);
|
||||
if (!string.IsNullOrWhiteSpace(contactToEmail) &&
|
||||
!recipients.Any(r => string.Equals(r, contactToEmail, StringComparison.OrdinalIgnoreCase)))
|
||||
recipients.Add(contactToEmail);
|
||||
|
||||
if (recipients.Count == 0) return;
|
||||
|
||||
var body = BuildBody(results);
|
||||
var subject = $"MyAi.ro: {results.Count} joburi potrivite CV-ului tau";
|
||||
var environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
var msg = new MimeMessage();
|
||||
msg.From.Add(MailboxAddress.Parse(smtpUser!));
|
||||
msg.To.Add(MailboxAddress.Parse(recipient));
|
||||
msg.Subject = $"[{environmentName}] {subject}";
|
||||
|
||||
var builder = new BodyBuilder { TextBody = body };
|
||||
if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath))
|
||||
builder.Attachments.Add(attachmentPath);
|
||||
|
||||
msg.Body = builder.ToMessageBody();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new SmtpClient();
|
||||
var tls2 = useStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||
await client.ConnectAsync(smtpHost, smtpPort, tls2, ct);
|
||||
if (!string.IsNullOrWhiteSpace(smtpUser))
|
||||
await client.AuthenticateAsync(smtpUser, smtpPass ?? string.Empty, ct);
|
||||
await client.SendAsync(msg, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
_logger.LogInformation("Job search results email sent to {Recipient}", recipient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send job search results email to {Recipient}", recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildBody(IReadOnlyList<JobSearchResultEntity> results)
|
||||
{
|
||||
if (results.Count == 0)
|
||||
return "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.";
|
||||
|
||||
var lines = new System.Text.StringBuilder();
|
||||
lines.AppendLine($"MyAi.ro a gasit {results.Count} joburi potrivite CV-ului tau:");
|
||||
lines.AppendLine();
|
||||
|
||||
for (int i = 0; i < results.Count; i++)
|
||||
{
|
||||
var r = results[i];
|
||||
var matchResp = TryParseResult(r.ResultJson);
|
||||
lines.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]");
|
||||
lines.AppendLine($" {r.JobUrl}");
|
||||
if (matchResp is not null && !string.IsNullOrWhiteSpace(matchResp.Summary))
|
||||
lines.AppendLine($" {matchResp.Summary}");
|
||||
lines.AppendLine();
|
||||
}
|
||||
|
||||
return lines.ToString();
|
||||
}
|
||||
|
||||
private static JobMatchResponse? TryParseResult(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<JobMatchResponse>(json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
using CvSearch.Models.Settings;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CvSearchJob.Services;
|
||||
|
||||
public sealed class HtmlJobSearcher
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<HtmlJobSearcher> _logger;
|
||||
|
||||
public HtmlJobSearcher(HttpClient http, ILogger<HtmlJobSearcher> logger)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
_http.Timeout = TimeSpan.FromSeconds(20);
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (compatible; MyAi.ro CV-Search/1.0)");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SearchJobUrlsAsync(
|
||||
JobProviderConfig provider,
|
||||
IReadOnlyList<string> cvKeywords,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var allKeywords = provider.InitialKeywords
|
||||
.Concat(cvKeywords)
|
||||
.Where(k => !string.IsNullOrWhiteSpace(k))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (allKeywords.Count == 0)
|
||||
return [];
|
||||
|
||||
var keywordsEncoded = HttpUtility.UrlEncode(string.Join(" ", allKeywords));
|
||||
var searchUrl = provider.SearchUrlTemplate.Replace("{keywords}", keywordsEncoded);
|
||||
|
||||
string html;
|
||||
try
|
||||
{
|
||||
html = await _http.GetStringAsync(searchUrl, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch search results from {Provider} at {Url}", provider.Name, searchUrl);
|
||||
return [];
|
||||
}
|
||||
|
||||
var baseUri = new Uri(searchUrl);
|
||||
var results = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Match all anchor tags capturing href and inner text
|
||||
var anchorPattern = new Regex(@"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
foreach (Match match in anchorPattern.Matches(html))
|
||||
{
|
||||
if (results.Count >= provider.MaxResults) break;
|
||||
|
||||
var href = match.Groups[1].Value.Trim();
|
||||
var anchorText = Regex.Replace(match.Groups[2].Value, "<[^>]+>", " ").Trim();
|
||||
|
||||
if (!href.Contains(provider.JobLinkContains, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// Stage 2: anchor text must contain at least one CV keyword
|
||||
if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
// Make absolute URL
|
||||
if (!Uri.TryCreate(href, UriKind.Absolute, out var absoluteUri))
|
||||
{
|
||||
if (!Uri.TryCreate(baseUri, href, out absoluteUri))
|
||||
continue;
|
||||
}
|
||||
|
||||
var url = absoluteUri.GetLeftPart(UriPartial.Path);
|
||||
if (seen.Add(url))
|
||||
results.Add(url);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Provider {Provider}: found {Count} job URLs", provider.Name, results.Count);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using System.Text.Json;
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvSearch.Models.Data;
|
||||
using CvSearch.Models.Data.Entities;
|
||||
using CvSearch.Models.Settings;
|
||||
using CvSearchJob.Clients;
|
||||
using CvSearchJob.Services;
|
||||
using JobScheduler.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace CvSearchJob.Tasks;
|
||||
|
||||
public sealed class CvSearchJobTask : IJobTask
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly JobSearchSettings _settings;
|
||||
private readonly HtmlJobSearcher _searcher;
|
||||
private readonly ICvMatcherInternalApi _matcherApi;
|
||||
private readonly CvSearchEmailSender _emailSender;
|
||||
private readonly ILogger<CvSearchJobTask> _logger;
|
||||
private readonly string _fileStoragePath;
|
||||
|
||||
public string TaskType => "CvSearch";
|
||||
|
||||
public CvSearchJobTask(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<JobSearchSettings> settings,
|
||||
HtmlJobSearcher searcher,
|
||||
ICvMatcherInternalApi matcherApi,
|
||||
CvSearchEmailSender emailSender,
|
||||
IConfiguration config,
|
||||
ILogger<CvSearchJobTask> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settings = settings.Value;
|
||||
_searcher = searcher;
|
||||
_matcherApi = matcherApi;
|
||||
_emailSender = emailSender;
|
||||
_logger = logger;
|
||||
_fileStoragePath = config["FileStorage:Path"] ?? "Files";
|
||||
if (!Path.IsPathRooted(_fileStoragePath))
|
||||
_fileStoragePath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _fileStoragePath));
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_settings.Enabled) return;
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
|
||||
|
||||
// Recover orphaned Processing sessions (container crashed mid-run)
|
||||
var stuckCutoff = DateTime.UtcNow.AddMinutes(-10);
|
||||
var stuckSessions = await db.JobSearchSessions
|
||||
.Where(s => s.Status == JobSearchStatus.Processing && s.CreatedAt < stuckCutoff)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var stuck in stuckSessions)
|
||||
{
|
||||
stuck.Status = JobSearchStatus.Pending;
|
||||
_logger.LogWarning("Reset stuck session {SessionId} back to Pending", stuck.Id);
|
||||
}
|
||||
if (stuckSessions.Count > 0)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var pending = await db.JobSearchSessions
|
||||
.Where(s => s.Status == JobSearchStatus.Pending)
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.Take(1)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (pending is null) return;
|
||||
|
||||
_logger.LogInformation("Processing job search session {SessionId}", pending.Id);
|
||||
pending.Status = JobSearchStatus.Processing;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var results = await RunSearchAsync(pending, db, cancellationToken);
|
||||
|
||||
pending.Status = JobSearchStatus.Done;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var attachmentPath = BuildCvPath(pending.CvDocumentId);
|
||||
await _emailSender.SendResultsAsync(pending.Email, attachmentPath, results, cancellationToken);
|
||||
_logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Session {SessionId} failed.", pending.Id);
|
||||
pending.Status = JobSearchStatus.Failed;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<JobSearchResultEntity>> RunSearchAsync(
|
||||
JobSearchSessionEntity session,
|
||||
CvSearchDbContext db,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cvKeywords = session.Keywords
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(k => k.Trim())
|
||||
.Where(k => k.Length > 0)
|
||||
.ToList();
|
||||
|
||||
var providers = GetProviders(session.ProviderConfigJson);
|
||||
var jobUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
var urls = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, ct);
|
||||
foreach (var url in urls) jobUrls.Add(url);
|
||||
}
|
||||
|
||||
var candidates = jobUrls.Take(_settings.MaxJobsToMatch).ToList();
|
||||
_logger.LogInformation("Session {SessionId}: {Count} candidate job URLs to match", session.Id, candidates.Count);
|
||||
|
||||
var results = new List<JobSearchResultEntity>();
|
||||
|
||||
foreach (var url in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var matchRequest = new MatchJobRequest
|
||||
{
|
||||
CvDocumentId = session.CvDocumentId,
|
||||
JobUrl = url,
|
||||
GdprConsent = true
|
||||
};
|
||||
|
||||
var matchResult = await _matcherApi.MatchJobAsync(matchRequest, ct);
|
||||
if (matchResult.Score < _settings.MinMatchScore)
|
||||
{
|
||||
_logger.LogDebug("Session {SessionId}: {Url} scored {Score}% (below threshold)", session.Id, url, matchResult.Score);
|
||||
continue;
|
||||
}
|
||||
|
||||
var entity = new JobSearchResultEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
SessionId = session.Id,
|
||||
ProviderName = GuessProvider(url, providers),
|
||||
JobUrl = url,
|
||||
JobTitle = matchResult.Summary.Split('.').FirstOrDefault()?.Trim() ?? "Job",
|
||||
JobText = string.Empty,
|
||||
Score = matchResult.Score,
|
||||
ResultJson = JsonSerializer.Serialize(matchResult, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.JobSearchResults.Add(entity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
results.Add(entity);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Session {SessionId}: match failed for {Url}", session.Id, url);
|
||||
}
|
||||
}
|
||||
|
||||
results.Sort((a, b) => b.Score.CompareTo(a.Score));
|
||||
return results;
|
||||
}
|
||||
|
||||
private List<JobProviderConfig> GetProviders(string? providerConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(providerConfigJson)) return _settings.Providers.Where(p => p.Enabled).ToList();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<JobProviderConfig>>(providerConfigJson,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web))
|
||||
?? _settings.Providers.Where(p => p.Enabled).ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return _settings.Providers.Where(p => p.Enabled).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GuessProvider(string url, List<JobProviderConfig> providers)
|
||||
{
|
||||
foreach (var p in providers)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(p.JobLinkContains) &&
|
||||
url.Contains(p.JobLinkContains, StringComparison.OrdinalIgnoreCase))
|
||||
return p.Name;
|
||||
}
|
||||
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : "unknown";
|
||||
}
|
||||
|
||||
private string BuildCvPath(string cvDocumentId)
|
||||
{
|
||||
var safeId = string.Concat(cvDocumentId.Where(char.IsLetterOrDigit));
|
||||
if (string.IsNullOrWhiteSpace(safeId)) safeId = "cv";
|
||||
return Path.Combine(_fileStoragePath, $"{safeId}.pdf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Microsoft.Extensions.Hosting": "Information",
|
||||
"System.Net.Http.HttpClient": "Warning",
|
||||
"CvSearchJob": "Information",
|
||||
"JobScheduler": "Information"
|
||||
}
|
||||
},
|
||||
"LogEnvironmentOnStartup": true,
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.Extensions.Hosting": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"System.Net.Http.HttpClient": "Warning",
|
||||
"CvSearchJob": "Information",
|
||||
"JobScheduler": "Information"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "logs/cv-search-job-.log",
|
||||
"rollingInterval": "Day",
|
||||
"retainedFileCountLimit": 30,
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "Email",
|
||||
"Args": {
|
||||
"restrictedToMinimumLevel": "Error",
|
||||
"fromEmail": "",
|
||||
"toEmail": "",
|
||||
"mailServer": "",
|
||||
"networkCredential": {
|
||||
"userName": "",
|
||||
"password": ""
|
||||
},
|
||||
"port": 587,
|
||||
"enableSsl": true,
|
||||
"emailSubject": "[mihes.ro CV search job] Error Alert",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
"batchPostingLimit": 10,
|
||||
"period": "0.00:05:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithEnvironmentName"
|
||||
]
|
||||
},
|
||||
"Database": {
|
||||
"Host": "localhost",
|
||||
"Port": 1433,
|
||||
"Name": "MyAiDb",
|
||||
"User": "sa",
|
||||
"Password": "",
|
||||
"TrustServerCertificate": true
|
||||
},
|
||||
"CvMatcherApi": {
|
||||
"BaseUrl": "http://cv-matcher-api:8080",
|
||||
"InternalApiKey": ""
|
||||
},
|
||||
"FileStorage": {
|
||||
"Path": "Files"
|
||||
},
|
||||
"Smtp": {
|
||||
"Host": "",
|
||||
"Port": 587,
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"UseStartTls": false
|
||||
},
|
||||
"Contact": {
|
||||
"ToEmail": ""
|
||||
},
|
||||
"JobSearch": {
|
||||
"Enabled": true,
|
||||
"JobSearchLinkBaseUrl": "https://myai.ro",
|
||||
"TokenExpiryDays": 7,
|
||||
"MinMatchScore": 15,
|
||||
"MaxJobsToMatch": 15,
|
||||
"Providers": [
|
||||
{
|
||||
"Name": "ejobs.ro",
|
||||
"Enabled": false,
|
||||
"SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/",
|
||||
"JobLinkContains": "/user/locuri-de-munca/job/",
|
||||
"InitialKeywords": [],
|
||||
"MaxResults": 20
|
||||
},
|
||||
{
|
||||
"Name": "bestjobs.eu",
|
||||
"Enabled": false,
|
||||
"SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}",
|
||||
"JobLinkContains": "/ro/locuri-de-munca/",
|
||||
"InitialKeywords": [],
|
||||
"MaxResults": 20
|
||||
},
|
||||
{
|
||||
"Name": "linkedin.com",
|
||||
"Enabled": false,
|
||||
"SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania",
|
||||
"JobLinkContains": "/jobs/view/",
|
||||
"InitialKeywords": [],
|
||||
"MaxResults": 20
|
||||
}
|
||||
]
|
||||
},
|
||||
"Jobs": {
|
||||
"Tasks": [
|
||||
{
|
||||
"TaskType": "CvSearch",
|
||||
"Enabled": true,
|
||||
"Interval": "00:00:30"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>CvSearchJob</RootNamespace>
|
||||
<AssemblyName>cv-search-job</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="logs\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Apis\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
||||
<ProjectReference Include="..\..\Apis\cv-search-models\cv-search-models.csproj" />
|
||||
<ProjectReference Include="..\..\Apis\shared-models\shared-models.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
<ProjectReference Include="..\job-scheduler\job-scheduler.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -191,6 +191,9 @@ services:
|
||||
- CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080}
|
||||
- CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key}
|
||||
|
||||
# JobSearch: base URL used to build the job search link in match emails
|
||||
- JobSearch__BaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro}
|
||||
|
||||
# Rate Limiting: matches api appsettings RateLimiting section
|
||||
- RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120}
|
||||
- RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00}
|
||||
@@ -274,6 +277,78 @@ services:
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
cv-search-job:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Jobs/cv-search-job/Dockerfile
|
||||
container_name: myai-cv-search-job
|
||||
depends_on:
|
||||
- cv-matcher-api
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Worker + diagnostics
|
||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
|
||||
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local}
|
||||
- LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true}
|
||||
|
||||
# Database
|
||||
- Database__Host=${Database__Host:-sqlserver}
|
||||
- Database__Port=${Database__Port:-1433}
|
||||
- Database__Name=${Database__Name:-MyAiDb}
|
||||
- Database__User=${Database__User:-sa}
|
||||
- Database__Password=${Database__Password:-}
|
||||
- Database__TrustServerCertificate=${Database__TrustServerCertificate:-true}
|
||||
|
||||
# CvMatcherApi (internal)
|
||||
- CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080}
|
||||
- CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key}
|
||||
|
||||
# SMTP
|
||||
- Smtp__Host=${Smtp__Host:-}
|
||||
- Smtp__Port=${Smtp__Port:-587}
|
||||
- Smtp__Username=${Smtp__Username:-}
|
||||
- Smtp__Password=${Smtp__Password:-}
|
||||
- Smtp__UseStartTls=${Smtp__UseStartTls:-false}
|
||||
|
||||
# Contact
|
||||
- Contact__ToEmail=${Contact__ToEmail:-}
|
||||
|
||||
# FileStorage (shared volume path must match api container)
|
||||
- FileStorage__Path=${FileStorage__Path:-Files}
|
||||
|
||||
# JobSearch settings
|
||||
- JobSearch__Enabled=${JobSearch__Enabled:-true}
|
||||
- JobSearch__JobSearchLinkBaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro}
|
||||
- JobSearch__TokenExpiryDays=${JobSearch__TokenExpiryDays:-7}
|
||||
- JobSearch__MinMatchScore=${JobSearch__MinMatchScore:-15}
|
||||
- JobSearch__MaxJobsToMatch=${JobSearch__MaxJobsToMatch:-15}
|
||||
|
||||
# Job task schedule
|
||||
- Jobs__Tasks__0__TaskType=CvSearch
|
||||
- Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true}
|
||||
- Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30}
|
||||
|
||||
# Logging / Serilog
|
||||
- Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information}
|
||||
- Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning}
|
||||
- Logging__LogLevel__Microsoft__Hosting__Lifetime=${Logging__LogLevel__Microsoft__Hosting__Lifetime:-Information}
|
||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||
volumes:
|
||||
- ../Jobs/cv-search-job/logs:/app/logs
|
||||
- ../Apis/api/Files:/app/Files
|
||||
networks:
|
||||
- myai-network
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
|
||||
@@ -16,6 +16,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Apis", "Apis", "{0FE6558F-2
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-matcher-api-models", "Apis\cv-matcher-api-models\cv-matcher-api-models.csproj", "{D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-models", "Apis\cv-search-models\cv-search-models.csproj", "{B2C3D4E5-F6A7-4890-BCDE-F01234567890}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api-models", "Apis\api-models\api-models.csproj", "{FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "rag-api-models", "Apis\rag-api-models\rag-api-models.csproj", "{6A1ADA81-28E9-4A64-A32D-0755876D5EB7}"
|
||||
@@ -32,6 +34,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jobs", "Jobs", "{F1A2B3C4-D
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-cleanup-job", "Jobs\cv-cleanup-job\cv-cleanup-job.csproj", "{E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-job", "Jobs\cv-search-job\cv-search-job.csproj", "{C3D4E5F6-A7B8-4901-CDEF-012345678901}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "job-scheduler", "Jobs\job-scheduler\job-scheduler.csproj", "{A19D2776-B935-BD35-4AB1-3FCE2092805A}"
|
||||
EndProject
|
||||
Global
|
||||
@@ -92,6 +96,14 @@ Global
|
||||
{A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -106,7 +118,9 @@ Global
|
||||
{185A8BB0-344A-4856-AEB4-213866EB2EE7} = {0FE6558F-2157-47F2-A835-558416CE0E2B}
|
||||
{7446D193-8636-4E58-96E4-0C8CB8790679} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284}
|
||||
{4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284}
|
||||
{B2C3D4E5-F6A7-4890-BCDE-F01234567890} = {0FE6558F-2157-47F2-A835-558416CE0E2B}
|
||||
{E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789}
|
||||
{C3D4E5F6-A7B8-4901-CDEF-012345678901} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789}
|
||||
{A19D2776-B935-BD35-4AB1-3FCE2092805A} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
|
||||
Reference in New Issue
Block a user