Main build #10
@@ -3,6 +3,9 @@
|
|||||||
##
|
##
|
||||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# Claude Code session files
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Environment Variables - DO NOT COMMIT
|
# Environment Variables - DO NOT COMMIT
|
||||||
*.env
|
*.env
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -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 Api.Clients.Api.Contracts;
|
||||||
|
using CvMatcher.Models.Requests;
|
||||||
|
using CvMatcher.Models.Responses;
|
||||||
using Models.Requests;
|
using Models.Requests;
|
||||||
using Models.Settings;
|
using Models.Settings;
|
||||||
using Api.Services.Contracts;
|
using Api.Services.Contracts;
|
||||||
@@ -20,21 +22,27 @@ namespace Api.Controllers;
|
|||||||
public sealed class CvMatcherController : ControllerBase
|
public sealed class CvMatcherController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ICvMatcherApi _cvApi;
|
private readonly ICvMatcherApi _cvApi;
|
||||||
|
private readonly IJobSearchApi _jobSearchApi;
|
||||||
private readonly ICaptchaVerifier _captcha;
|
private readonly ICaptchaVerifier _captcha;
|
||||||
private readonly FileStorageSettings _fileStorageSettings;
|
private readonly FileStorageSettings _fileStorageSettings;
|
||||||
|
private readonly JobSearchLinkSettings _jobSearchLinkSettings;
|
||||||
private readonly IEmailSender _emailSender;
|
private readonly IEmailSender _emailSender;
|
||||||
private readonly ILogger<CvMatcherController> _logger;
|
private readonly ILogger<CvMatcherController> _logger;
|
||||||
|
|
||||||
public CvMatcherController(
|
public CvMatcherController(
|
||||||
ICvMatcherApi cvApi,
|
ICvMatcherApi cvApi,
|
||||||
|
IJobSearchApi jobSearchApi,
|
||||||
ICaptchaVerifier captcha,
|
ICaptchaVerifier captcha,
|
||||||
IOptions<FileStorageSettings> fileStorageSettings,
|
IOptions<FileStorageSettings> fileStorageSettings,
|
||||||
|
IOptions<JobSearchLinkSettings> jobSearchLinkSettings,
|
||||||
IEmailSender emailSender,
|
IEmailSender emailSender,
|
||||||
ILogger<CvMatcherController> logger)
|
ILogger<CvMatcherController> logger)
|
||||||
{
|
{
|
||||||
_cvApi = cvApi;
|
_cvApi = cvApi;
|
||||||
|
_jobSearchApi = jobSearchApi;
|
||||||
_captcha = captcha;
|
_captcha = captcha;
|
||||||
_fileStorageSettings = fileStorageSettings.Value;
|
_fileStorageSettings = fileStorageSettings.Value;
|
||||||
|
_jobSearchLinkSettings = jobSearchLinkSettings.Value;
|
||||||
_emailSender = emailSender;
|
_emailSender = emailSender;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -136,10 +144,27 @@ public sealed class CvMatcherController : ControllerBase
|
|||||||
? request.JobUrl
|
? request.JobUrl
|
||||||
: "Manual job description";
|
: "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(
|
await _emailSender.SendMatchAsync(
|
||||||
request.Email,
|
request.Email,
|
||||||
SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel),
|
SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel),
|
||||||
SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel),
|
SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, jobSearchLink),
|
||||||
attachmentPath,
|
attachmentPath,
|
||||||
ct);
|
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)
|
private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
+8
-7
@@ -28,27 +28,28 @@ try
|
|||||||
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||||
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
|
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
|
||||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||||
|
builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||||
|
|
||||||
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
|
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
|
||||||
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
|
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
|
||||||
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
||||||
|
|
||||||
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
|
static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client)
|
||||||
.ConfigureHttpClient((sp, client) =>
|
|
||||||
{
|
{
|
||||||
var config = sp.GetRequiredService<IConfiguration>();
|
var config = sp.GetRequiredService<IConfiguration>();
|
||||||
var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty;
|
var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty;
|
||||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||||
{
|
|
||||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||||
}
|
|
||||||
|
|
||||||
var key = config["CvMatcherApi:InternalApiKey"];
|
var key = config["CvMatcherApi:InternalApiKey"];
|
||||||
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
|
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
|
||||||
{
|
|
||||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", 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.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "API");
|
||||||
builder.Services.ConfigureCaddyForwardedHeaders();
|
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}
|
CV Document ID: {cvDocumentId}
|
||||||
Job: {jobLabel ?? "N/A"}
|
Job: {jobLabel ?? "N/A"}
|
||||||
@@ -233,6 +235,19 @@ Gaps:
|
|||||||
Recommendations:
|
Recommendations:
|
||||||
- {string.Join("\n- ", result.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)
|
public static string BuildMatchEmailSubject(int score, string? jobLabel)
|
||||||
=> $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}";
|
=> $"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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ ARG BUILD_CONFIGURATION=Release
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY Apis/cv-matcher-api/cv-matcher-api.csproj Apis/cv-matcher-api/
|
COPY Apis/cv-matcher-api/cv-matcher-api.csproj Apis/cv-matcher-api/
|
||||||
|
COPY Apis/cv-search-models/cv-search-models.csproj Apis/cv-search-models/
|
||||||
COPY Apis/shared-models/shared-models.csproj Apis/shared-models/
|
COPY Apis/shared-models/shared-models.csproj Apis/shared-models/
|
||||||
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
|
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
|
||||||
COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/
|
COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/
|
||||||
@@ -11,6 +12,7 @@ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
|
|||||||
RUN dotnet restore Apis/cv-matcher-api/cv-matcher-api.csproj
|
RUN dotnet restore Apis/cv-matcher-api/cv-matcher-api.csproj
|
||||||
|
|
||||||
COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/
|
COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/
|
||||||
|
COPY Apis/cv-search-models/ Apis/cv-search-models/
|
||||||
COPY Apis/shared-models/ Apis/shared-models/
|
COPY Apis/shared-models/ Apis/shared-models/
|
||||||
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
|
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
|
||||||
COPY Helpers/common-helpers/ Helpers/common-helpers/
|
COPY Helpers/common-helpers/ Helpers/common-helpers/
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ using Api.Data.Repositories.Contracts;
|
|||||||
using Api.Services;
|
using Api.Services;
|
||||||
using Api.Services.Contracts;
|
using Api.Services.Contracts;
|
||||||
using CvMatcher.Models.Settings;
|
using CvMatcher.Models.Settings;
|
||||||
|
using CvSearch.Models.Data;
|
||||||
|
using CvSearch.Models.Settings;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Refit;
|
using Refit;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -34,6 +36,7 @@ try
|
|||||||
builder.Services.Configure<InternalApiSettings>(builder.Configuration.GetSection("InternalApi"));
|
builder.Services.Configure<InternalApiSettings>(builder.Configuration.GetSection("InternalApi"));
|
||||||
builder.Services.Configure<CvMatcher.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai"));
|
builder.Services.Configure<CvMatcher.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai"));
|
||||||
builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher"));
|
builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher"));
|
||||||
|
builder.Services.Configure<JobSearchSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||||
|
|
||||||
builder.Services.AddRefitClient<IRefitRagApi>()
|
builder.Services.AddRefitClient<IRefitRagApi>()
|
||||||
.ConfigureHttpClient((sp, c) =>
|
.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<IMatcherRepository, EfMatcherRepository>();
|
||||||
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
|
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
|
||||||
|
builder.Services.AddScoped<IJobTokenService, JobTokenService>();
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName);
|
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName);
|
||||||
@@ -90,6 +104,11 @@ try
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<CvMatcherDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<CvMatcherDbContext>();
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
}
|
}
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
|
||||||
|
db.Database.Migrate();
|
||||||
|
}
|
||||||
|
|
||||||
Log.Information("{Service} startup complete", ServiceName);
|
Log.Information("{Service} startup complete", ServiceName);
|
||||||
app.Run();
|
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,
|
"TopK": 10,
|
||||||
"DeepScoreTopN": 5,
|
"DeepScoreTopN": 5,
|
||||||
"MaxJobTextChars": 60000
|
"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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
|
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
|
||||||
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.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="..\shared-models\shared-models.csproj" />
|
||||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||||
</ItemGroup>
|
</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>
|
||||||
@@ -2,6 +2,17 @@
|
|||||||
# Copy this file to `.env` (local), `.env.staging`, or `.env.production` and fill the secret values.
|
# Copy this file to `.env` (local), `.env.staging`, or `.env.production` and fill the secret values.
|
||||||
# Do NOT commit your `.env.*` files containing real secrets.
|
# Do NOT commit your `.env.*` files containing real secrets.
|
||||||
|
|
||||||
|
# Docker image tag — must match the tag CI pushes to the registry for this environment.
|
||||||
|
# "staging" for the staging Portainer stack, "production" for the production stack.
|
||||||
|
# For local dev this is ignored (docker-compose.override.yml builds images locally).
|
||||||
|
IMAGE_TAG=staging
|
||||||
|
|
||||||
|
# Volume base paths — controls where logs and uploaded files are stored on the host.
|
||||||
|
# Portainer (staging/prod): leave unset to use the /opt/myai defaults.
|
||||||
|
# Local dev: set to relative paths so logs and files land in the repo tree.
|
||||||
|
LOGS_PATH=./logs
|
||||||
|
FILES_PATH=../Apis/api/Files
|
||||||
|
|
||||||
# Common
|
# Common
|
||||||
ASPNETCORE_ENVIRONMENT=Development
|
ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
|
||||||
@@ -81,6 +92,15 @@ Jobs__CvStorageCleanupEnabled=true
|
|||||||
Jobs__CvStorageCleanupInterval=01:00:00
|
Jobs__CvStorageCleanupInterval=01:00:00
|
||||||
Jobs__CvStorageMaxTotalSizeMegabytes=40
|
Jobs__CvStorageMaxTotalSizeMegabytes=40
|
||||||
|
|
||||||
|
# CV search job (job board scraper — triggered by one-click email link)
|
||||||
|
Jobs__CvSearchEnabled=true
|
||||||
|
Jobs__CvSearchInterval=00:00:30
|
||||||
|
JobSearch__Enabled=true
|
||||||
|
JobSearch__JobSearchLinkBaseUrl=https://myai.ro
|
||||||
|
JobSearch__TokenExpiryDays=7
|
||||||
|
JobSearch__MinMatchScore=15
|
||||||
|
JobSearch__MaxJobsToMatch=15
|
||||||
|
|
||||||
# File Storage
|
# File Storage
|
||||||
FileStorage__Path=Files
|
FileStorage__Path=Files
|
||||||
FileStorage__DefaultFileName=
|
FileStorage__DefaultFileName=
|
||||||
|
|||||||
@@ -20,5 +20,8 @@
|
|||||||
<DependentUpon>.env</DependentUpon>
|
<DependentUpon>.env</DependentUpon>
|
||||||
</None>
|
</None>
|
||||||
<None Include="docker-compose.yml" />
|
<None Include="docker-compose.yml" />
|
||||||
|
<None Include="docker-compose.override.yml">
|
||||||
|
<DependentUpon>docker-compose.yml</DependentUpon>
|
||||||
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Local development overrides — auto-merged by "docker compose up".
|
||||||
|
# Do NOT paste this into Portainer. It only adds build context, port mappings,
|
||||||
|
# and env_file loading on top of docker-compose.yml.
|
||||||
|
|
||||||
|
services:
|
||||||
|
rag-api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Apis/rag-api/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8081:8080"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
cv-matcher-api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Apis/cv-matcher-api/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8082:8080"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Apis/api/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
cv-cleanup-job:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Jobs/cv-cleanup-job/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
cv-search-job:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Jobs/cv-search-job/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: web/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "5000:8080"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
rag-api:
|
rag-api:
|
||||||
image: registry.easysoft.ro/apps/myai-rag-api:staging
|
image: registry.easysoft.ro/apps/myai-rag-api:${IMAGE_TAG:-staging}
|
||||||
container_name: myai-rag-api
|
container_name: myai-rag-api
|
||||||
environment:
|
environment:
|
||||||
# ASP.NET
|
|
||||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
||||||
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
|
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
|
||||||
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
||||||
- LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true}
|
|
||||||
|
|
||||||
# Database: matches rag-api appsettings Database section
|
|
||||||
- Database__Host=${Database__Host:-sqlserver}
|
- Database__Host=${Database__Host:-sqlserver}
|
||||||
- Database__Port=${Database__Port:-1433}
|
- Database__Port=${Database__Port:-1433}
|
||||||
- Database__Name=${Database__Name:-MyAiDb}
|
- Database__Name=${Database__Name:-MyAiDb}
|
||||||
@@ -17,11 +14,9 @@ services:
|
|||||||
- Database__Password=${Database__Password:-}
|
- Database__Password=${Database__Password:-}
|
||||||
- Database__TrustServerCertificate=${Database__TrustServerCertificate:-true}
|
- Database__TrustServerCertificate=${Database__TrustServerCertificate:-true}
|
||||||
|
|
||||||
# InternalApi: matches rag-api appsettings InternalApi section
|
- InternalApi__ApiKey=${RagApi__InternalApiKey:-}
|
||||||
- InternalApi__ApiKey=${RagApi__InternalApiKey:-change-this-internal-key}
|
- InternalApi__RequireApiKey=${RagApi__RequireApiKey:-true}
|
||||||
- InternalApi__RequireApiKey=${RagApi__RequireApiKey:-false}
|
|
||||||
|
|
||||||
# Rag: matches rag-api appsettings Rag section
|
|
||||||
- Rag__MaxFileSizeMb=${Rag__MaxFileSizeMb:-8}
|
- Rag__MaxFileSizeMb=${Rag__MaxFileSizeMb:-8}
|
||||||
- Rag__ChunkSize=${Rag__ChunkSize:-900}
|
- Rag__ChunkSize=${Rag__ChunkSize:-900}
|
||||||
- Rag__ChunkOverlap=${Rag__ChunkOverlap:-150}
|
- Rag__ChunkOverlap=${Rag__ChunkOverlap:-150}
|
||||||
@@ -30,7 +25,6 @@ services:
|
|||||||
- Rag__MaxTopK=${Rag__MaxTopK:-50}
|
- Rag__MaxTopK=${Rag__MaxTopK:-50}
|
||||||
- Rag__ClassifyWithAi=${Rag__ClassifyWithAi:-false}
|
- Rag__ClassifyWithAi=${Rag__ClassifyWithAi:-false}
|
||||||
|
|
||||||
# Ai: matches rag-api appsettings Ai section
|
|
||||||
- Ai__Provider=${Ai__Provider:-OpenAI}
|
- Ai__Provider=${Ai__Provider:-OpenAI}
|
||||||
- Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-}
|
- Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-}
|
||||||
- Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini}
|
- Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini}
|
||||||
@@ -41,11 +35,6 @@ services:
|
|||||||
- Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text}
|
- Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text}
|
||||||
- Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180}
|
- Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180}
|
||||||
|
|
||||||
# Logging / Serilog
|
|
||||||
- Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information}
|
|
||||||
- Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning}
|
|
||||||
- Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning}
|
|
||||||
- Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information}
|
|
||||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||||
@@ -54,7 +43,7 @@ services:
|
|||||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/myai/logs/rag-api:/app/logs
|
- ${LOGS_PATH:-/opt/myai/logs}/rag-api:/app/logs
|
||||||
networks:
|
networks:
|
||||||
- myai-network
|
- myai-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -62,18 +51,15 @@ services:
|
|||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|
||||||
cv-matcher-api:
|
cv-matcher-api:
|
||||||
image: registry.easysoft.ro/apps/myai-cv-matcher-api:staging
|
image: registry.easysoft.ro/apps/myai-cv-matcher-api:${IMAGE_TAG:-staging}
|
||||||
container_name: myai-cv-matcher-api
|
container_name: myai-cv-matcher-api
|
||||||
depends_on:
|
depends_on:
|
||||||
- rag-api
|
- rag-api
|
||||||
environment:
|
environment:
|
||||||
# ASP.NET
|
|
||||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
||||||
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
|
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
|
||||||
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
||||||
- LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true}
|
|
||||||
|
|
||||||
# Database: matches cv-matcher-api appsettings Database section
|
|
||||||
- Database__Host=${Database__Host:-sqlserver}
|
- Database__Host=${Database__Host:-sqlserver}
|
||||||
- Database__Port=${Database__Port:-1433}
|
- Database__Port=${Database__Port:-1433}
|
||||||
- Database__Name=${Database__Name:-MyAiDb}
|
- Database__Name=${Database__Name:-MyAiDb}
|
||||||
@@ -81,15 +67,12 @@ services:
|
|||||||
- Database__Password=${Database__Password:-}
|
- Database__Password=${Database__Password:-}
|
||||||
- Database__TrustServerCertificate=${Database__TrustServerCertificate:-true}
|
- Database__TrustServerCertificate=${Database__TrustServerCertificate:-true}
|
||||||
|
|
||||||
# InternalApi: matches cv-matcher-api appsettings InternalApi section
|
- InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-}
|
||||||
- InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key}
|
- InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-true}
|
||||||
- InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-false}
|
|
||||||
|
|
||||||
# RagApi: matches cv-matcher-api appsettings RagApi section
|
|
||||||
- RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080}
|
- RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080}
|
||||||
- RagApi__InternalApiKey=${RagApi__InternalApiKey:-change-this-internal-key}
|
- RagApi__InternalApiKey=${RagApi__InternalApiKey:-}
|
||||||
|
|
||||||
# Ai: matches cv-matcher-api appsettings Ai section
|
|
||||||
- Ai__Provider=${Ai__Provider:-OpenAI}
|
- Ai__Provider=${Ai__Provider:-OpenAI}
|
||||||
- Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-}
|
- Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-}
|
||||||
- Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini}
|
- Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini}
|
||||||
@@ -98,16 +81,10 @@ services:
|
|||||||
- Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b}
|
- Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b}
|
||||||
- Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180}
|
- Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180}
|
||||||
|
|
||||||
# Matcher: matches cv-matcher-api appsettings Matcher section
|
|
||||||
- Matcher__TopK=${Matcher__TopK:-10}
|
- Matcher__TopK=${Matcher__TopK:-10}
|
||||||
- Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5}
|
- Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5}
|
||||||
- Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000}
|
- Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000}
|
||||||
|
|
||||||
# Logging / Serilog
|
|
||||||
- Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information}
|
|
||||||
- Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning}
|
|
||||||
- Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning}
|
|
||||||
- Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information}
|
|
||||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||||
@@ -116,7 +93,7 @@ services:
|
|||||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/myai/logs/cv-matcher-api:/app/logs
|
- ${LOGS_PATH:-/opt/myai/logs}/cv-matcher-api:/app/logs
|
||||||
networks:
|
networks:
|
||||||
- myai-network
|
- myai-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -124,53 +101,43 @@ services:
|
|||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
image: registry.easysoft.ro/apps/myai-api:staging
|
image: registry.easysoft.ro/apps/myai-api:${IMAGE_TAG:-staging}
|
||||||
container_name: myai-api
|
container_name: myai-api
|
||||||
depends_on:
|
depends_on:
|
||||||
- cv-matcher-api
|
- cv-matcher-api
|
||||||
environment:
|
environment:
|
||||||
# ASP.NET
|
|
||||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
||||||
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
|
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
|
||||||
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
||||||
- LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true}
|
|
||||||
|
|
||||||
# Google: matches api appsettings Google section
|
|
||||||
- Google__TagManagerId=${Google__TagManagerId:-}
|
- Google__TagManagerId=${Google__TagManagerId:-}
|
||||||
- Google__MapKey=${Google__MapKey:-}
|
- Google__MapKey=${Google__MapKey:-}
|
||||||
|
|
||||||
# Contact / Subscribe: matches api appsettings Contact and Subscribe sections
|
|
||||||
- Contact__ToEmail=${Contact__ToEmail:-}
|
- Contact__ToEmail=${Contact__ToEmail:-}
|
||||||
- Contact__FromEmail=${Contact__FromEmail:-${Smtp__Username:-}}
|
- Contact__FromEmail=${Contact__FromEmail:-}
|
||||||
- Contact__SubjectPrefix=${Contact__SubjectPrefix:-}
|
- Contact__SubjectPrefix=${Contact__SubjectPrefix:-}
|
||||||
- Subscribe__ToEmail=${Subscribe__ToEmail:-}
|
- Subscribe__ToEmail=${Subscribe__ToEmail:-}
|
||||||
- Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-}
|
- Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-}
|
||||||
|
|
||||||
# SMTP: matches api appsettings Smtp section
|
- Smtp__Host=${Smtp__Host:-}
|
||||||
- Smtp__Host=${Smtp__Host:-mail.example.com}
|
|
||||||
- Smtp__Port=${Smtp__Port:-587}
|
- Smtp__Port=${Smtp__Port:-587}
|
||||||
- Smtp__Username=${Smtp__Username:-}
|
- Smtp__Username=${Smtp__Username:-}
|
||||||
- Smtp__Password=${Smtp__Password:-}
|
- Smtp__Password=${Smtp__Password:-}
|
||||||
- Smtp__UseStartTls=${Smtp__UseStartTls:-false}
|
- Smtp__UseStartTls=${Smtp__UseStartTls:-false}
|
||||||
|
|
||||||
# Captcha: matches api appsettings Captcha section
|
|
||||||
- Captcha__Provider=${Captcha__Provider:-Recaptcha}
|
- Captcha__Provider=${Captcha__Provider:-Recaptcha}
|
||||||
- Captcha__SecretKey=${Captcha__SecretKey:-}
|
- Captcha__SecretKey=${Captcha__SecretKey:-}
|
||||||
- Captcha__PublicKey=${Captcha__PublicKey:-}
|
- Captcha__PublicKey=${Captcha__PublicKey:-}
|
||||||
- Captcha__MinimumScore=${Captcha__MinimumScore:-0.5}
|
- Captcha__MinimumScore=${Captcha__MinimumScore:-0.5}
|
||||||
|
|
||||||
# FileStorage: matches api appsettings FileStorage section
|
|
||||||
- FileStorage__Path=${FileStorage__Path:-Files}
|
- FileStorage__Path=${FileStorage__Path:-Files}
|
||||||
- FileStorage__DefaultFileName=${FileStorage__DefaultFileName:-}
|
- FileStorage__DefaultFileName=${FileStorage__DefaultFileName:-}
|
||||||
- FileStorage__ToEmail=${FileStorage__ToEmail:-}
|
|
||||||
- FileStorage__FromEmail=${FileStorage__FromEmail:-${Smtp__Username:-}}
|
|
||||||
- FileStorage__SubjectPrefix=${FileStorage__SubjectPrefix:-[File Download]}
|
|
||||||
|
|
||||||
# CvMatcherApi: matches api appsettings CvMatcherApi section
|
|
||||||
- CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080}
|
- CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080}
|
||||||
- CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key}
|
- CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-}
|
||||||
|
|
||||||
|
- JobSearch__BaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro}
|
||||||
|
|
||||||
# Rate Limiting: matches api appsettings RateLimiting section
|
|
||||||
- RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120}
|
- RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120}
|
||||||
- RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00}
|
- RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00}
|
||||||
- RateLimiting__Global__QueueLimit=${RateLimiting__Global__QueueLimit:-0}
|
- RateLimiting__Global__QueueLimit=${RateLimiting__Global__QueueLimit:-0}
|
||||||
@@ -181,15 +148,9 @@ services:
|
|||||||
- RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00}
|
- RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00}
|
||||||
- RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0}
|
- RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0}
|
||||||
|
|
||||||
# CORS: not in the uploaded api appsettings, but used by your API startup config.
|
- Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-}
|
||||||
- Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-http://localhost:5000}
|
- Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-}
|
||||||
- Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-http://web:8080}
|
|
||||||
|
|
||||||
# Logging / Serilog
|
|
||||||
- Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information}
|
|
||||||
- Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning}
|
|
||||||
- Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning}
|
|
||||||
- Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information}
|
|
||||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||||
@@ -198,8 +159,8 @@ services:
|
|||||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/myai/logs/api:/app/logs
|
- ${LOGS_PATH:-/opt/myai/logs}/api:/app/logs
|
||||||
- /opt/myai/files:/app/Files
|
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||||
networks:
|
networks:
|
||||||
- myai-network
|
- myai-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -207,32 +168,20 @@ services:
|
|||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|
||||||
cv-cleanup-job:
|
cv-cleanup-job:
|
||||||
image: registry.easysoft.ro/apps/myai-cv-cleanup-job:staging
|
image: registry.easysoft.ro/apps/myai-cv-cleanup-job:${IMAGE_TAG:-staging}
|
||||||
container_name: myai-cv-cleanup-job
|
container_name: myai-cv-cleanup-job
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
environment:
|
environment:
|
||||||
# Worker + diagnostics (matches Jobs/cv-cleanup-job appsettings)
|
|
||||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
||||||
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
||||||
- LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true}
|
|
||||||
|
|
||||||
# FileStorage: matches cv-cleanup-job appsettings FileStorage section
|
- FileStorage__Path=${FileStorage__Path:-Files}
|
||||||
- FileStorage__Path=Files
|
|
||||||
|
|
||||||
# Jobs: matches cv-cleanup-job appsettings Jobs:Tasks
|
|
||||||
- Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true}
|
- Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true}
|
||||||
- Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00}
|
- Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00}
|
||||||
- Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40}
|
- Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40}
|
||||||
|
|
||||||
# Logging / Serilog (matches Jobs/cv-cleanup-job appsettings Serilog section; WriteTo index 2 = Email)
|
|
||||||
- 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}
|
|
||||||
- Logging__LogLevel__CvCleanupJob=${Logging__LogLevel__CvCleanupJob:-Information}
|
|
||||||
- Logging__LogLevel__JobScheduler=${Logging__LogLevel__JobScheduler:-Information}
|
|
||||||
- Serilog__MinimumLevel__Override__CvCleanupJob=${Serilog__MinimumLevel__Override__CvCleanupJob:-Information}
|
|
||||||
- Serilog__MinimumLevel__Override__JobScheduler=${Serilog__MinimumLevel__Override__JobScheduler:-Information}
|
|
||||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||||
@@ -241,8 +190,63 @@ services:
|
|||||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/myai/logs/cv-cleanup-job:/app/logs
|
- ${LOGS_PATH:-/opt/myai/logs}/cv-cleanup-job:/app/logs
|
||||||
- /opt/myai/files:/app/Files
|
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||||
|
networks:
|
||||||
|
- myai-network
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|
||||||
|
cv-search-job:
|
||||||
|
image: registry.easysoft.ro/apps/myai-cv-search-job:${IMAGE_TAG:-staging}
|
||||||
|
container_name: myai-cv-search-job
|
||||||
|
depends_on:
|
||||||
|
- cv-matcher-api
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
||||||
|
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
||||||
|
|
||||||
|
- 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__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080}
|
||||||
|
- CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-}
|
||||||
|
|
||||||
|
- Smtp__Host=${Smtp__Host:-}
|
||||||
|
- Smtp__Port=${Smtp__Port:-587}
|
||||||
|
- Smtp__Username=${Smtp__Username:-}
|
||||||
|
- Smtp__Password=${Smtp__Password:-}
|
||||||
|
- Smtp__UseStartTls=${Smtp__UseStartTls:-false}
|
||||||
|
|
||||||
|
- Contact__ToEmail=${Contact__ToEmail:-}
|
||||||
|
|
||||||
|
- FileStorage__Path=${FileStorage__Path:-Files}
|
||||||
|
|
||||||
|
- 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}
|
||||||
|
|
||||||
|
- Jobs__Tasks__0__TaskType=CvSearch
|
||||||
|
- Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true}
|
||||||
|
- Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30}
|
||||||
|
|
||||||
|
- 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:
|
||||||
|
- ${LOGS_PATH:-/opt/myai/logs}/cv-search-job:/app/logs
|
||||||
|
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||||
networks:
|
networks:
|
||||||
- myai-network
|
- myai-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -250,18 +254,15 @@ services:
|
|||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: registry.easysoft.ro/apps/myai-web:staging
|
image: registry.easysoft.ro/apps/myai-web:${IMAGE_TAG:-staging}
|
||||||
container_name: myai-web
|
container_name: myai-web
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
ports:
|
|
||||||
- "5140:8080"
|
|
||||||
environment:
|
environment:
|
||||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
||||||
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
|
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
|
||||||
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
||||||
|
|
||||||
# Site: matches web appsettings Site section (Normal, UnderConstruction, Unavailable)
|
|
||||||
- Site__Mode=${Site__Mode:-Normal}
|
- Site__Mode=${Site__Mode:-Normal}
|
||||||
networks:
|
networks:
|
||||||
- myai-network
|
- myai-network
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Apis", "Apis", "{0FE6558F-2
|
|||||||
EndProject
|
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}"
|
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
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api-models", "Apis\api-models\api-models.csproj", "{FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "rag-api-models", "Apis\rag-api-models\rag-api-models.csproj", "{6A1ADA81-28E9-4A64-A32D-0755876D5EB7}"
|
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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-cleanup-job", "Jobs\cv-cleanup-job\cv-cleanup-job.csproj", "{E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-cleanup-job", "Jobs\cv-cleanup-job\cv-cleanup-job.csproj", "{E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}"
|
||||||
EndProject
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "job-scheduler", "Jobs\job-scheduler\job-scheduler.csproj", "{A19D2776-B935-BD35-4AB1-3FCE2092805A}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
@@ -92,6 +96,14 @@ Global
|
|||||||
{A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{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.ActiveCfg = Release|Any CPU
|
||||||
{A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -106,7 +118,9 @@ Global
|
|||||||
{185A8BB0-344A-4856-AEB4-213866EB2EE7} = {0FE6558F-2157-47F2-A835-558416CE0E2B}
|
{185A8BB0-344A-4856-AEB4-213866EB2EE7} = {0FE6558F-2157-47F2-A835-558416CE0E2B}
|
||||||
{7446D193-8636-4E58-96E4-0C8CB8790679} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284}
|
{7446D193-8636-4E58-96E4-0C8CB8790679} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284}
|
||||||
{4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3} = {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}
|
{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}
|
{A19D2776-B935-BD35-4AB1-3FCE2092805A} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
|||||||
Reference in New Issue
Block a user