From a0ae262afca9ec0a91f7c9d608d92b6706795e85 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Wed, 20 May 2026 21:16:34 +0300 Subject: [PATCH 1/5] Main build --- docker-compose/docker-compose.dcproj | 6 - docker-compose/docker-compose.production.yml | 272 ------------------- docker-compose/docker-compose.staging.yml | 272 ------------------- 3 files changed, 550 deletions(-) delete mode 100644 docker-compose/docker-compose.production.yml delete mode 100644 docker-compose/docker-compose.staging.yml diff --git a/docker-compose/docker-compose.dcproj b/docker-compose/docker-compose.dcproj index 09171bd..327f8f1 100644 --- a/docker-compose/docker-compose.dcproj +++ b/docker-compose/docker-compose.dcproj @@ -20,11 +20,5 @@ .env - - docker-compose.yml - - - docker-compose.yml - \ No newline at end of file diff --git a/docker-compose/docker-compose.production.yml b/docker-compose/docker-compose.production.yml deleted file mode 100644 index 4144943..0000000 --- a/docker-compose/docker-compose.production.yml +++ /dev/null @@ -1,272 +0,0 @@ -services: - rag-api: - image: registry.easysoft.ro/apps/myai-rag-api:production - container_name: myai-rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Database: matches rag-api appsettings Database section - - 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} - - # InternalApi: matches rag-api appsettings InternalApi section - - InternalApi__ApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-false} - - # Rag: matches rag-api appsettings Rag section - - Rag__MaxFileSizeMb=${Rag__MaxFileSizeMb:-8} - - Rag__ChunkSize=${Rag__ChunkSize:-900} - - Rag__ChunkOverlap=${Rag__ChunkOverlap:-150} - - Rag__MaxTextChars=${Rag__MaxTextChars:-60000} - - Rag__DefaultTopK=${Rag__DefaultTopK:-20} - - Rag__MaxTopK=${Rag__MaxTopK:-50} - - Rag__ClassifyWithAi=${Rag__ClassifyWithAi:-false} - - # Ai: matches rag-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__EmbeddingModel=${Ai__OpenAI__EmbeddingModel:-text-embedding-3-small} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text} - - 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__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: - - /opt/myai/logs/rag-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-matcher-api: - image: registry.easysoft.ro/apps/myai-cv-matcher-api:production - container_name: myai-cv-matcher-api - depends_on: - - rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Database: matches cv-matcher-api appsettings Database section - - 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} - - # InternalApi: matches cv-matcher-api appsettings InternalApi section - - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-false} - - # RagApi: matches cv-matcher-api appsettings RagApi section - - RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080} - - RagApi__InternalApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - # Ai: matches cv-matcher-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - # Matcher: matches cv-matcher-api appsettings Matcher section - - Matcher__TopK=${Matcher__TopK:-10} - - Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5} - - 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__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: - - /opt/myai/logs/cv-matcher-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - api: - image: registry.easysoft.ro/apps/myai-api:production - container_name: myai-api - depends_on: - - cv-matcher-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Google: matches api appsettings Google section - - Google__TagManagerId=${Google__TagManagerId:-} - - Google__MapKey=${Google__MapKey:-} - - # Contact / Subscribe: matches api appsettings Contact and Subscribe sections - - Contact__ToEmail=${Contact__ToEmail:-} - - Contact__FromEmail=${Contact__FromEmail:-${Smtp__Username:-}} - - Contact__SubjectPrefix=${Contact__SubjectPrefix:-} - - Subscribe__ToEmail=${Subscribe__ToEmail:-} - - Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-} - - # SMTP: matches api appsettings Smtp section - - Smtp__Host=${Smtp__Host:-mail.example.com} - - Smtp__Port=${Smtp__Port:-587} - - Smtp__Username=${Smtp__Username:-} - - Smtp__Password=${Smtp__Password:-} - - Smtp__UseStartTls=${Smtp__UseStartTls:-false} - - # Captcha: matches api appsettings Captcha section - - Captcha__Provider=${Captcha__Provider:-Recaptcha} - - Captcha__SecretKey=${Captcha__SecretKey:-} - - Captcha__PublicKey=${Captcha__PublicKey:-} - - Captcha__MinimumScore=${Captcha__MinimumScore:-0.5} - - # FileStorage: matches api appsettings FileStorage section - - FileStorage__Path=${FileStorage__Path:-Files} - - 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__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - # Rate Limiting: matches api appsettings RateLimiting section - - RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120} - - RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00} - - RateLimiting__Global__QueueLimit=${RateLimiting__Global__QueueLimit:-0} - - RateLimiting__Policies__contact__PermitLimit=${RateLimiting__Policies__contact__PermitLimit:-5} - - RateLimiting__Policies__contact__Window=${RateLimiting__Policies__contact__Window:-00:01:00} - - RateLimiting__Policies__contact__QueueLimit=${RateLimiting__Policies__contact__QueueLimit:-0} - - RateLimiting__Policies__cvMatcher__PermitLimit=${RateLimiting__Policies__cvMatcher__PermitLimit:-10} - - RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00} - - 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:-http://localhost:5000} - - 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__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: - - /opt/myai/logs/api:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-cleanup-job: - image: registry.easysoft.ro/apps/myai-cv-cleanup-job:production - container_name: myai-cv-cleanup-job - depends_on: - - api - environment: - # Worker + diagnostics (matches Jobs/cv-cleanup-job appsettings) - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # FileStorage: matches cv-cleanup-job appsettings FileStorage section - - FileStorage__Path=Files - - # Jobs: matches cv-cleanup-job appsettings Jobs:Tasks - - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} - - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} - - 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__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: - - /opt/myai/logs/cv-cleanup-job:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - web: - image: registry.easysoft.ro/apps/myai-web:production - container_name: myai-web - depends_on: - - api - ports: - - "5140:8080" - environment: - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - # Site: matches web appsettings Site section (Normal, UnderConstruction, Unavailable) - - Site__Mode=${Site__Mode:-Normal} - networks: - - myai-network - restart: unless-stopped - -networks: - myai-network: - driver: bridge \ No newline at end of file diff --git a/docker-compose/docker-compose.staging.yml b/docker-compose/docker-compose.staging.yml deleted file mode 100644 index da7ae99..0000000 --- a/docker-compose/docker-compose.staging.yml +++ /dev/null @@ -1,272 +0,0 @@ -services: - rag-api: - image: registry.easysoft.ro/apps/myai-rag-api:staging - container_name: myai-rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Database: matches rag-api appsettings Database section - - 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} - - # InternalApi: matches rag-api appsettings InternalApi section - - InternalApi__ApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-false} - - # Rag: matches rag-api appsettings Rag section - - Rag__MaxFileSizeMb=${Rag__MaxFileSizeMb:-8} - - Rag__ChunkSize=${Rag__ChunkSize:-900} - - Rag__ChunkOverlap=${Rag__ChunkOverlap:-150} - - Rag__MaxTextChars=${Rag__MaxTextChars:-60000} - - Rag__DefaultTopK=${Rag__DefaultTopK:-20} - - Rag__MaxTopK=${Rag__MaxTopK:-50} - - Rag__ClassifyWithAi=${Rag__ClassifyWithAi:-false} - - # Ai: matches rag-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__EmbeddingModel=${Ai__OpenAI__EmbeddingModel:-text-embedding-3-small} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text} - - 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__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: - - /opt/myai/logs/rag-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-matcher-api: - image: registry.easysoft.ro/apps/myai-cv-matcher-api:staging - container_name: myai-cv-matcher-api - depends_on: - - rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - 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__Port=${Database__Port:-1433} - - Database__Name=${Database__Name:-MyAiDb} - - Database__User=${Database__User:-sa} - - Database__Password=${Database__Password:-} - - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - - # InternalApi: matches cv-matcher-api appsettings InternalApi section - - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-false} - - # RagApi: matches cv-matcher-api appsettings RagApi section - - RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080} - - RagApi__InternalApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - # Ai: matches cv-matcher-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - # Matcher: matches cv-matcher-api appsettings Matcher section - - Matcher__TopK=${Matcher__TopK:-10} - - Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5} - - 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__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: - - /opt/myai/logs/cv-matcher-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - api: - image: registry.easysoft.ro/apps/myai-api:staging - container_name: myai-api - depends_on: - - cv-matcher-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Google: matches api appsettings Google section - - Google__TagManagerId=${Google__TagManagerId:-} - - Google__MapKey=${Google__MapKey:-} - - # Contact / Subscribe: matches api appsettings Contact and Subscribe sections - - Contact__ToEmail=${Contact__ToEmail:-} - - Contact__FromEmail=${Contact__FromEmail:-${Smtp__Username:-}} - - Contact__SubjectPrefix=${Contact__SubjectPrefix:-} - - Subscribe__ToEmail=${Subscribe__ToEmail:-} - - Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-} - - # SMTP: matches api appsettings Smtp section - - Smtp__Host=${Smtp__Host:-mail.example.com} - - Smtp__Port=${Smtp__Port:-587} - - Smtp__Username=${Smtp__Username:-} - - Smtp__Password=${Smtp__Password:-} - - Smtp__UseStartTls=${Smtp__UseStartTls:-false} - - # Captcha: matches api appsettings Captcha section - - Captcha__Provider=${Captcha__Provider:-Recaptcha} - - Captcha__SecretKey=${Captcha__SecretKey:-} - - Captcha__PublicKey=${Captcha__PublicKey:-} - - Captcha__MinimumScore=${Captcha__MinimumScore:-0.5} - - # FileStorage: matches api appsettings FileStorage section - - FileStorage__Path=${FileStorage__Path:-Files} - - 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__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - # Rate Limiting: matches api appsettings RateLimiting section - - RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120} - - RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00} - - RateLimiting__Global__QueueLimit=${RateLimiting__Global__QueueLimit:-0} - - RateLimiting__Policies__contact__PermitLimit=${RateLimiting__Policies__contact__PermitLimit:-5} - - RateLimiting__Policies__contact__Window=${RateLimiting__Policies__contact__Window:-00:01:00} - - RateLimiting__Policies__contact__QueueLimit=${RateLimiting__Policies__contact__QueueLimit:-0} - - RateLimiting__Policies__cvMatcher__PermitLimit=${RateLimiting__Policies__cvMatcher__PermitLimit:-10} - - RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00} - - 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:-http://localhost:5000} - - 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__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: - - /opt/myai/logs/api:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-cleanup-job: - image: registry.easysoft.ro/apps/myai-cv-cleanup-job:staging - container_name: myai-cv-cleanup-job - depends_on: - - api - environment: - # Worker + diagnostics (matches Jobs/cv-cleanup-job appsettings) - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # FileStorage: matches cv-cleanup-job appsettings FileStorage section - - FileStorage__Path=Files - - # Jobs: matches cv-cleanup-job appsettings Jobs:Tasks - - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} - - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} - - 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__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: - - /opt/myai/logs/cv-cleanup-job:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - web: - image: registry.easysoft.ro/apps/myai-web:staging - container_name: myai-web - depends_on: - - api - ports: - - "5140:8080" - environment: - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - # Site: matches web appsettings Site section (Normal, UnderConstruction, Unavailable) - - Site__Mode=${Site__Mode:-Normal} - networks: - - myai-network - restart: unless-stopped - -networks: - myai-network: - driver: bridge \ No newline at end of file From 6293fa89e37c93096b2f8ba92e6362ed4a561515 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Fri, 22 May 2026 17:56:23 +0300 Subject: [PATCH 2/5] Add internet job search feature (cv-search-job) - New cv-search-models shared library: EF entities + CvSearchDbContext for cvSearch schema (JobSearchTokens, JobSearchSessions, JobSearchResults tables) - New cv-search-job worker service: polls DB for pending sessions, scrapes job boards via configurable HTML scraping, runs LLM scoring via cv-matcher-api, emails ranked results - cv-matcher-api: JobTokenService creates one-time tokens; JobSearchController handles link clicks and creates sessions - api: proxies job-search start endpoint, appends job search link to match result email - CI workflow updated to build and push myai-cv-search-job:staging image - CLAUDE.md documentation added for all affected services Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build.yml | 11 +- .../Settings/JobSearchLinkSettings.cs | 6 + Apis/api/CLAUDE.md | 45 ++++ .../Clients/Api/Contracts/IJobSearchApi.cs | 14 ++ Apis/api/Controllers/CvMatcherController.cs | 66 +++++- Apis/api/Program.cs | 31 +-- Apis/api/Services/SmtpEmailSender.cs | 17 +- .../Requests/CreateJobSearchTokenRequest.cs | 7 + .../Responses/CreateJobSearchTokenResponse.cs | 6 + .../Responses/StartJobSearchResponse.cs | 14 ++ Apis/cv-matcher-api/CLAUDE.md | 60 ++++++ .../Controllers/JobSearchController.cs | 56 +++++ Apis/cv-matcher-api/Program.cs | 19 ++ .../Services/Contracts/IJobTokenService.cs | 7 + .../Services/JobTokenService.cs | 107 +++++++++ Apis/cv-matcher-api/appsettings.json | 33 +++ Apis/cv-matcher-api/cv-matcher-api.csproj | 1 + .../Data/CvSearchDbContext.cs | 60 ++++++ .../Data/Entities/JobSearchResultEntity.cs | 14 ++ .../Data/Entities/JobSearchSessionEntity.cs | 21 ++ .../Data/Entities/JobSearchTokenEntity.cs | 11 + ...60522093356_AddJobSearchTables.Designer.cs | 160 ++++++++++++++ .../20260522093356_AddJobSearchTables.cs | 102 +++++++++ .../CvSearchDbContextModelSnapshot.cs | 157 ++++++++++++++ .../Settings/JobSearchSettings.cs | 21 ++ Apis/cv-search-models/cv-search-models.csproj | 18 ++ CLAUDE.md | 158 ++++++++++++++ Jobs/cv-search-job/CLAUDE.md | 90 ++++++++ .../Clients/ICvMatcherInternalApi.cs | 11 + Jobs/cv-search-job/Dockerfile | 28 +++ Jobs/cv-search-job/Program.cs | 86 ++++++++ .../Services/CvSearchEmailSender.cs | 108 ++++++++++ .../cv-search-job/Services/HtmlJobSearcher.cs | 86 ++++++++ Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 203 ++++++++++++++++++ Jobs/cv-search-job/appsettings.json | 139 ++++++++++++ Jobs/cv-search-job/cv-search-job.csproj | 30 +++ docker-compose/docker-compose.yml | 75 +++++++ myAi.sln | 14 ++ 38 files changed, 2074 insertions(+), 18 deletions(-) create mode 100644 Apis/api-models/Settings/JobSearchLinkSettings.cs create mode 100644 Apis/api/CLAUDE.md create mode 100644 Apis/api/Clients/Api/Contracts/IJobSearchApi.cs create mode 100644 Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs create mode 100644 Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs create mode 100644 Apis/cv-matcher-api-models/Responses/StartJobSearchResponse.cs create mode 100644 Apis/cv-matcher-api/CLAUDE.md create mode 100644 Apis/cv-matcher-api/Controllers/JobSearchController.cs create mode 100644 Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs create mode 100644 Apis/cv-matcher-api/Services/JobTokenService.cs create mode 100644 Apis/cv-search-models/Data/CvSearchDbContext.cs create mode 100644 Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs create mode 100644 Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs create mode 100644 Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs create mode 100644 Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs create mode 100644 Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs create mode 100644 Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs create mode 100644 Apis/cv-search-models/Settings/JobSearchSettings.cs create mode 100644 Apis/cv-search-models/cv-search-models.csproj create mode 100644 CLAUDE.md create mode 100644 Jobs/cv-search-job/CLAUDE.md create mode 100644 Jobs/cv-search-job/Clients/ICvMatcherInternalApi.cs create mode 100644 Jobs/cv-search-job/Dockerfile create mode 100644 Jobs/cv-search-job/Program.cs create mode 100644 Jobs/cv-search-job/Services/CvSearchEmailSender.cs create mode 100644 Jobs/cv-search-job/Services/HtmlJobSearcher.cs create mode 100644 Jobs/cv-search-job/Tasks/CvSearchJobTask.cs create mode 100644 Jobs/cv-search-job/appsettings.json create mode 100644 Jobs/cv-search-job/cv-search-job.csproj diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index a89e1fb..778c6e5 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -13,6 +13,7 @@ env: RAG_API_IMAGE: apps/myai-rag-api WEB_IMAGE: apps/myai-web CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job + CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job IMAGE_TAG: staging jobs: @@ -52,6 +53,10 @@ jobs: run: | docker build -f Jobs/cv-cleanup-job/Dockerfile -t "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" . + - name: Build CV search job image + run: | + docker build -f Jobs/cv-search-job/Dockerfile -t "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" . + - name: Push API image run: | docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}" @@ -70,4 +75,8 @@ jobs: - name: Push CV cleanup job image run: | - docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" \ No newline at end of file + docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" + + - name: Push CV search job image + run: | + docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" \ No newline at end of file diff --git a/Apis/api-models/Settings/JobSearchLinkSettings.cs b/Apis/api-models/Settings/JobSearchLinkSettings.cs new file mode 100644 index 0000000..50955d9 --- /dev/null +++ b/Apis/api-models/Settings/JobSearchLinkSettings.cs @@ -0,0 +1,6 @@ +namespace Models.Settings; + +public sealed class JobSearchLinkSettings +{ + public string BaseUrl { get; set; } = string.Empty; +} diff --git a/Apis/api/CLAUDE.md b/Apis/api/CLAUDE.md new file mode 100644 index 0000000..2e56219 --- /dev/null +++ b/Apis/api/CLAUDE.md @@ -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=`) +- 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=` | 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. diff --git a/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs b/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs new file mode 100644 index 0000000..1a8ec60 --- /dev/null +++ b/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs @@ -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 CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct); + + [Post("/api/cv/job-search/token/{tokenId}/start")] + Task StartSearchAsync(string tokenId, CancellationToken ct); +} diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 2488ae8..8ae87cf 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -1,4 +1,6 @@ using Api.Clients.Api.Contracts; +using CvMatcher.Models.Requests; +using CvMatcher.Models.Responses; using Models.Requests; using Models.Settings; using Api.Services.Contracts; @@ -20,21 +22,27 @@ namespace Api.Controllers; public sealed class CvMatcherController : ControllerBase { private readonly ICvMatcherApi _cvApi; + private readonly IJobSearchApi _jobSearchApi; private readonly ICaptchaVerifier _captcha; private readonly FileStorageSettings _fileStorageSettings; + private readonly JobSearchLinkSettings _jobSearchLinkSettings; private readonly IEmailSender _emailSender; private readonly ILogger _logger; public CvMatcherController( ICvMatcherApi cvApi, + IJobSearchApi jobSearchApi, ICaptchaVerifier captcha, IOptions fileStorageSettings, + IOptions jobSearchLinkSettings, IEmailSender emailSender, ILogger logger) { _cvApi = cvApi; + _jobSearchApi = jobSearchApi; _captcha = captcha; _fileStorageSettings = fileStorageSettings.Value; + _jobSearchLinkSettings = jobSearchLinkSettings.Value; _emailSender = emailSender; _logger = logger; } @@ -136,10 +144,27 @@ public sealed class CvMatcherController : ControllerBase ? request.JobUrl : "Manual job description"; + string? jobSearchLink = null; + if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId)) + { + try + { + var tokenResp = await _jobSearchApi.CreateTokenAsync( + new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email }, + ct); + var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/'); + jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}"; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not create job search token. Email link will be omitted."); + } + } + await _emailSender.SendMatchAsync( request.Email, SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel), - SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel), + SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, jobSearchLink), attachmentPath, ct); @@ -157,6 +182,45 @@ public sealed class CvMatcherController : ControllerBase } } + [HttpGet("job-search/start")] + [SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a simple HTML confirmation page.")] + public async Task 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) => $$""" + + + {{title}} - MyAi.ro + + +

{{title}}

{{message}}

+ + """; + private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct) { try diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index 78c6beb..c10ce85 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -28,27 +28,28 @@ try builder.Services.Configure(builder.Configuration.GetSection("Smtp")); builder.Services.Configure(builder.Configuration.GetSection("Captcha")); builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); + builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddRefitClient() - .ConfigureHttpClient((sp, client) => - { - var config = sp.GetRequiredService(); - var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty; - if (!string.IsNullOrWhiteSpace(baseUrl)) - { - client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); - } + static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client) + { + var config = sp.GetRequiredService(); + var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty; + if (!string.IsNullOrWhiteSpace(baseUrl)) + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + var key = config["CvMatcherApi:InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key")) + client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); + } - var key = config["CvMatcherApi:InternalApiKey"]; - if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key")) - { - client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); - } - }); + builder.Services.AddRefitClient() + .ConfigureHttpClient(ConfigureCvMatcherApiClient); + + builder.Services.AddRefitClient() + .ConfigureHttpClient(ConfigureCvMatcherApiClient); builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "API"); builder.Services.ConfigureCaddyForwardedHeaders(); diff --git a/Apis/api/Services/SmtpEmailSender.cs b/Apis/api/Services/SmtpEmailSender.cs index d854534..4dd3367 100644 --- a/Apis/api/Services/SmtpEmailSender.cs +++ b/Apis/api/Services/SmtpEmailSender.cs @@ -214,7 +214,9 @@ namespace Api.Services } } - public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel) => $@"CV Matcher result + public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string? jobSearchLink = null) + { + var body = $@"CV Matcher result CV Document ID: {cvDocumentId} Job: {jobLabel ?? "N/A"} @@ -233,6 +235,19 @@ Gaps: Recommendations: - {string.Join("\n- ", result.Recommendations)}"; + if (!string.IsNullOrWhiteSpace(jobSearchLink)) + { + body += $@" + +--- +Vrei sa gasesti mai multe joburi potrivite CV-ului tau? +Click: {jobSearchLink} +(link valabil 7 zile)"; + } + + return body; + } + public static string BuildMatchEmailSubject(int score, string? jobLabel) => $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}"; } diff --git a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs new file mode 100644 index 0000000..4a8f456 --- /dev/null +++ b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs @@ -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; +} diff --git a/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs new file mode 100644 index 0000000..489d0ef --- /dev/null +++ b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs @@ -0,0 +1,6 @@ +namespace CvMatcher.Models.Responses; + +public sealed class CreateJobSearchTokenResponse +{ + public string TokenId { get; set; } = string.Empty; +} diff --git a/Apis/cv-matcher-api-models/Responses/StartJobSearchResponse.cs b/Apis/cv-matcher-api-models/Responses/StartJobSearchResponse.cs new file mode 100644 index 0000000..f6de477 --- /dev/null +++ b/Apis/cv-matcher-api-models/Responses/StartJobSearchResponse.cs @@ -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"; +} diff --git a/Apis/cv-matcher-api/CLAUDE.md b/Apis/cv-matcher-api/CLAUDE.md new file mode 100644 index 0000000..a310eb4 --- /dev/null +++ b/Apis/cv-matcher-api/CLAUDE.md @@ -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 | diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs new file mode 100644 index 0000000..a646526 --- /dev/null +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -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 _logger; + + public JobSearchController(IJobTokenService tokenService, ILogger logger) + { + _tokenService = tokenService; + _logger = logger; + } + + [HttpPost("token")] + public async Task> 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> 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" }); + } + } +} diff --git a/Apis/cv-matcher-api/Program.cs b/Apis/cv-matcher-api/Program.cs index ce602b9..928a4e4 100644 --- a/Apis/cv-matcher-api/Program.cs +++ b/Apis/cv-matcher-api/Program.cs @@ -8,6 +8,8 @@ using Api.Data.Repositories.Contracts; using Api.Services; using Api.Services.Contracts; using CvMatcher.Models.Settings; +using CvSearch.Models.Data; +using CvSearch.Models.Settings; using Microsoft.EntityFrameworkCore; using Refit; using Serilog; @@ -34,6 +36,7 @@ try builder.Services.Configure(builder.Configuration.GetSection("InternalApi")); builder.Services.Configure(builder.Configuration.GetSection("Ai")); builder.Services.Configure(builder.Configuration.GetSection("Matcher")); + builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); builder.Services.AddRefitClient() .ConfigureHttpClient((sp, c) => @@ -61,8 +64,19 @@ try }); }); + builder.Services.AddDbContext(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(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName); @@ -90,6 +104,11 @@ try var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } Log.Information("{Service} startup complete", ServiceName); app.Run(); diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs new file mode 100644 index 0000000..f49edc4 --- /dev/null +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -0,0 +1,7 @@ +namespace Api.Services.Contracts; + +public interface IJobTokenService +{ + Task CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct); + Task TriggerStartAsync(string tokenId, CancellationToken ct); +} diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs new file mode 100644 index 0000000..7ec470b --- /dev/null +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -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 _logger; + + public JobTokenService( + CvSearchDbContext db, + IRagApiClient rag, + IOptions settings, + ILogger logger) + { + _db = db; + _rag = rag; + _settings = settings.Value; + _logger = logger; + } + + public async Task 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 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); + } +} diff --git a/Apis/cv-matcher-api/appsettings.json b/Apis/cv-matcher-api/appsettings.json index 0379e6c..9fe037c 100644 --- a/Apis/cv-matcher-api/appsettings.json +++ b/Apis/cv-matcher-api/appsettings.json @@ -106,5 +106,38 @@ "TopK": 10, "DeepScoreTopN": 5, "MaxJobTextChars": 60000 + }, + "JobSearch": { + "Enabled": true, + "JobSearchLinkBaseUrl": "https://myai.ro", + "TokenExpiryDays": 7, + "MinMatchScore": 15, + "MaxJobsToMatch": 15, + "Providers": [ + { + "Name": "ejobs.ro", + "Enabled": false, + "SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/", + "JobLinkContains": "/user/locuri-de-munca/job/", + "InitialKeywords": [], + "MaxResults": 20 + }, + { + "Name": "bestjobs.eu", + "Enabled": false, + "SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}", + "JobLinkContains": "/ro/locuri-de-munca/", + "InitialKeywords": [], + "MaxResults": 20 + }, + { + "Name": "linkedin.com", + "Enabled": false, + "SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania", + "JobLinkContains": "/jobs/view/", + "InitialKeywords": [], + "MaxResults": 20 + } + ] } } diff --git a/Apis/cv-matcher-api/cv-matcher-api.csproj b/Apis/cv-matcher-api/cv-matcher-api.csproj index 3abf857..edd7c95 100644 --- a/Apis/cv-matcher-api/cv-matcher-api.csproj +++ b/Apis/cv-matcher-api/cv-matcher-api.csproj @@ -79,6 +79,7 @@ + diff --git a/Apis/cv-search-models/Data/CvSearchDbContext.cs b/Apis/cv-search-models/Data/CvSearchDbContext.cs new file mode 100644 index 0000000..625ebff --- /dev/null +++ b/Apis/cv-search-models/Data/CvSearchDbContext.cs @@ -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 options) : base(options) { } + + public DbSet JobSearchTokens => Set(); + public DbSet JobSearchSessions => Set(); + public DbSet JobSearchResults => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(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(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(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); + }); + } +} diff --git a/Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs new file mode 100644 index 0000000..7cea013 --- /dev/null +++ b/Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs @@ -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; +} diff --git a/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs new file mode 100644 index 0000000..68f31d0 --- /dev/null +++ b/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs @@ -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"; +} diff --git a/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs new file mode 100644 index 0000000..02bab69 --- /dev/null +++ b/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs @@ -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; +} diff --git a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs new file mode 100644 index 0000000..475bd9b --- /dev/null +++ b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs @@ -0,0 +1,160 @@ +// +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 + { + /// + 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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs new file mode 100644 index 0000000..adbc233 --- /dev/null +++ b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs @@ -0,0 +1,102 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Models.Migrations +{ + /// + public partial class AddJobSearchTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "cvSearch"); + + migrationBuilder.CreateTable( + name: "JobSearchResults", + schema: "cvSearch", + columns: table => new + { + Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + SessionId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ProviderName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + JobUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: false), + JobTitle = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), + JobText = table.Column(type: "nvarchar(max)", nullable: false), + Score = table.Column(type: "int", nullable: false), + ResultJson = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), + TokenId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Keywords = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + ProviderConfigJson = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), + CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + Used = table.Column(type: "bit", nullable: false, defaultValue: false), + CreatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobSearchResults", + schema: "cvSearch"); + + migrationBuilder.DropTable( + name: "JobSearchSessions", + schema: "cvSearch"); + + migrationBuilder.DropTable( + name: "JobSearchTokens", + schema: "cvSearch"); + } + } +} diff --git a/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs new file mode 100644 index 0000000..e4c9e99 --- /dev/null +++ b/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs @@ -0,0 +1,157 @@ +// +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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-models/Settings/JobSearchSettings.cs b/Apis/cv-search-models/Settings/JobSearchSettings.cs new file mode 100644 index 0000000..2634509 --- /dev/null +++ b/Apis/cv-search-models/Settings/JobSearchSettings.cs @@ -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 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 InitialKeywords { get; set; } = []; + public int MaxResults { get; set; } = 20; +} diff --git a/Apis/cv-search-models/cv-search-models.csproj b/Apis/cv-search-models/cv-search-models.csproj new file mode 100644 index 0000000..310b3cf --- /dev/null +++ b/Apis/cv-search-models/cv-search-models.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + CvSearch.Models + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..52b200b --- /dev/null +++ b/CLAUDE.md @@ -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 \ + --context CvSearchDbContext \ + --project Apis/cv-search-models \ + --startup-project Apis/cv-matcher-api + +# Add a migration to cv-matcher-api +dotnet ef migrations add \ + --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>(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` — registered with `Configure(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`) diff --git a/Jobs/cv-search-job/CLAUDE.md b/Jobs/cv-search-job/CLAUDE.md new file mode 100644 index 0000000..788a1e2 --- /dev/null +++ b/Jobs/cv-search-job/CLAUDE.md @@ -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 `text` 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. diff --git a/Jobs/cv-search-job/Clients/ICvMatcherInternalApi.cs b/Jobs/cv-search-job/Clients/ICvMatcherInternalApi.cs new file mode 100644 index 0000000..8b42576 --- /dev/null +++ b/Jobs/cv-search-job/Clients/ICvMatcherInternalApi.cs @@ -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 MatchJobAsync([Body] MatchJobRequest request, CancellationToken ct); +} diff --git a/Jobs/cv-search-job/Dockerfile b/Jobs/cv-search-job/Dockerfile new file mode 100644 index 0000000..d1954c9 --- /dev/null +++ b/Jobs/cv-search-job/Dockerfile @@ -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"] diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs new file mode 100644 index 0000000..2fe815d --- /dev/null +++ b/Jobs/cv-search-job/Program.cs @@ -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(builder.Configuration.GetSection("JobSearch")); + builder.Services.Configure(builder.Configuration.GetSection("Database")); + + builder.Services.AddDbContext(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() + .ConfigureHttpClient((sp, client) => + { + var config = sp.GetRequiredService(); + 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(); + builder.Services.AddSingleton(); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton>(sp => new IJobTask[] + { + sp.GetRequiredService(), + }); + + builder.Services.AddHostedService(); + + 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(); + 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(); +} diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs new file mode 100644 index 0000000..4a0c531 --- /dev/null +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -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 _logger; + + public CvSearchEmailSender(IConfiguration config, ILogger logger) + { + _config = config; + _logger = logger; + } + + public async Task SendResultsAsync( + string toEmail, + string? attachmentPath, + IReadOnlyList 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(); + 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 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(json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); } + catch { return null; } + } +} diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs new file mode 100644 index 0000000..1ca539c --- /dev/null +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -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 _logger; + + public HtmlJobSearcher(HttpClient http, ILogger 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> SearchJobUrlsAsync( + JobProviderConfig provider, + IReadOnlyList 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(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Match all anchor tags capturing href and inner text + var anchorPattern = new Regex(@"]+href=[""']([^""']+)[""'][^>]*>(.*?)", + 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; + } +} diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs new file mode 100644 index 0000000..9f608f3 --- /dev/null +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -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 _logger; + private readonly string _fileStoragePath; + + public string TaskType => "CvSearch"; + + public CvSearchJobTask( + IServiceScopeFactory scopeFactory, + IOptions settings, + HtmlJobSearcher searcher, + ICvMatcherInternalApi matcherApi, + CvSearchEmailSender emailSender, + IConfiguration config, + ILogger 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(); + + // 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> 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(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(); + + 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 GetProviders(string? providerConfigJson) + { + if (string.IsNullOrWhiteSpace(providerConfigJson)) return _settings.Providers.Where(p => p.Enabled).ToList(); + try + { + return JsonSerializer.Deserialize>(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 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"); + } +} diff --git a/Jobs/cv-search-job/appsettings.json b/Jobs/cv-search-job/appsettings.json new file mode 100644 index 0000000..c3536fb --- /dev/null +++ b/Jobs/cv-search-job/appsettings.json @@ -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" + } + ] + } +} diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj new file mode 100644 index 0000000..7c38382 --- /dev/null +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + CvSearchJob + cv-search-job + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 4d8a526..982d0ec 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -191,6 +191,9 @@ services: - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} + # JobSearch: base URL used to build the job search link in match emails + - JobSearch__BaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro} + # Rate Limiting: matches api appsettings RateLimiting section - RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120} - RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00} @@ -274,6 +277,78 @@ services: labels: - "com.centurylinklabs.watchtower.enable=true" + cv-search-job: + build: + context: .. + dockerfile: Jobs/cv-search-job/Dockerfile + container_name: myai-cv-search-job + depends_on: + - cv-matcher-api + env_file: + - .env + environment: + # Worker + diagnostics + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} + - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + + # Database + - Database__Host=${Database__Host:-sqlserver} + - Database__Port=${Database__Port:-1433} + - Database__Name=${Database__Name:-MyAiDb} + - Database__User=${Database__User:-sa} + - Database__Password=${Database__Password:-} + - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} + + # CvMatcherApi (internal) + - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} + - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} + + # SMTP + - Smtp__Host=${Smtp__Host:-} + - Smtp__Port=${Smtp__Port:-587} + - Smtp__Username=${Smtp__Username:-} + - Smtp__Password=${Smtp__Password:-} + - Smtp__UseStartTls=${Smtp__UseStartTls:-false} + + # Contact + - Contact__ToEmail=${Contact__ToEmail:-} + + # FileStorage (shared volume path must match api container) + - FileStorage__Path=${FileStorage__Path:-Files} + + # JobSearch settings + - JobSearch__Enabled=${JobSearch__Enabled:-true} + - JobSearch__JobSearchLinkBaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro} + - JobSearch__TokenExpiryDays=${JobSearch__TokenExpiryDays:-7} + - JobSearch__MinMatchScore=${JobSearch__MinMatchScore:-15} + - JobSearch__MaxJobsToMatch=${JobSearch__MaxJobsToMatch:-15} + + # Job task schedule + - Jobs__Tasks__0__TaskType=CvSearch + - Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true} + - Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30} + + # Logging / Serilog + - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} + - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} + - Logging__LogLevel__Microsoft__Hosting__Lifetime=${Logging__LogLevel__Microsoft__Hosting__Lifetime:-Information} + - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} + - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} + - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} + - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} + - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} + - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} + - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + volumes: + - ../Jobs/cv-search-job/logs:/app/logs + - ../Apis/api/Files:/app/Files + networks: + - myai-network + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + web: build: context: .. diff --git a/myAi.sln b/myAi.sln index 85b28f1..db7111f 100644 --- a/myAi.sln +++ b/myAi.sln @@ -16,6 +16,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Apis", "Apis", "{0FE6558F-2 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-matcher-api-models", "Apis\cv-matcher-api-models\cv-matcher-api-models.csproj", "{D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-models", "Apis\cv-search-models\cv-search-models.csproj", "{B2C3D4E5-F6A7-4890-BCDE-F01234567890}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api-models", "Apis\api-models\api-models.csproj", "{FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "rag-api-models", "Apis\rag-api-models\rag-api-models.csproj", "{6A1ADA81-28E9-4A64-A32D-0755876D5EB7}" @@ -32,6 +34,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jobs", "Jobs", "{F1A2B3C4-D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-cleanup-job", "Jobs\cv-cleanup-job\cv-cleanup-job.csproj", "{E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-job", "Jobs\cv-search-job\cv-search-job.csproj", "{C3D4E5F6-A7B8-4901-CDEF-012345678901}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "job-scheduler", "Jobs\job-scheduler\job-scheduler.csproj", "{A19D2776-B935-BD35-4AB1-3FCE2092805A}" EndProject Global @@ -92,6 +96,14 @@ Global {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.Build.0 = Debug|Any CPU {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.ActiveCfg = Release|Any CPU {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -106,7 +118,9 @@ Global {185A8BB0-344A-4856-AEB4-213866EB2EE7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {7446D193-8636-4E58-96E4-0C8CB8790679} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} + {B2C3D4E5-F6A7-4890-BCDE-F01234567890} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} + {C3D4E5F6-A7B8-4901-CDEF-012345678901} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} {A19D2776-B935-BD35-4AB1-3FCE2092805A} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution From a4c128fdf4724e39c4d166b7393ad72d6d9bc0a3 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Fri, 22 May 2026 18:17:58 +0300 Subject: [PATCH 3/5] Fix cv-matcher-api Dockerfile: add cv-search-models to build context dotnet restore failed in CI because cv-search-models.csproj was added as a ProjectReference but not copied into the Docker build context. Co-Authored-By: Claude Sonnet 4.6 --- Apis/cv-matcher-api/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Apis/cv-matcher-api/Dockerfile b/Apis/cv-matcher-api/Dockerfile index 343c535..0d1c2f9 100644 --- a/Apis/cv-matcher-api/Dockerfile +++ b/Apis/cv-matcher-api/Dockerfile @@ -3,6 +3,7 @@ ARG BUILD_CONFIGURATION=Release WORKDIR /src 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/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ 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 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/cv-matcher-api-models/ Apis/cv-matcher-api-models/ COPY Helpers/common-helpers/ Helpers/common-helpers/ From cf064531c5d8e95cd81229bcde8923bc12e2f6b5 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Fri, 22 May 2026 18:52:39 +0300 Subject: [PATCH 4/5] Refactor docker-compose: single deployable file + local override - docker-compose.yml is now the single file for Portainer (staging and prod). Uses registry images with ${IMAGE_TAG:-staging}, ${LOGS_PATH:-/opt/myai/logs}, and ${FILES_PATH:-/opt/myai/files} so the same file works for all environments. - docker-compose.override.yml adds build context, ports, and env_file for local dev and is auto-merged by "docker compose up" (no extra flags needed). - .env.template documents IMAGE_TAG, LOGS_PATH, FILES_PATH alongside existing vars. - docker-compose.dcproj updated so override file nests under docker-compose.yml in Solution Explorer. Co-Authored-By: Claude Sonnet 4.6 --- docker-compose/.env.template | 20 +++ docker-compose/docker-compose.dcproj | 3 + docker-compose/docker-compose.override.yml | 54 +++++++ docker-compose/docker-compose.yml | 177 +++++---------------- 4 files changed, 114 insertions(+), 140 deletions(-) create mode 100644 docker-compose/docker-compose.override.yml diff --git a/docker-compose/.env.template b/docker-compose/.env.template index 8ad75e6..3e24c53 100644 --- a/docker-compose/.env.template +++ b/docker-compose/.env.template @@ -2,6 +2,17 @@ # 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. +# 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 ASPNETCORE_ENVIRONMENT=Development @@ -81,6 +92,15 @@ Jobs__CvStorageCleanupEnabled=true Jobs__CvStorageCleanupInterval=01:00:00 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 FileStorage__Path=Files FileStorage__DefaultFileName= diff --git a/docker-compose/docker-compose.dcproj b/docker-compose/docker-compose.dcproj index 327f8f1..1d32a8a 100644 --- a/docker-compose/docker-compose.dcproj +++ b/docker-compose/docker-compose.dcproj @@ -20,5 +20,8 @@ .env
+ + docker-compose.yml + \ No newline at end of file diff --git a/docker-compose/docker-compose.override.yml b/docker-compose/docker-compose.override.yml new file mode 100644 index 0000000..c87ef2b --- /dev/null +++ b/docker-compose/docker-compose.override.yml @@ -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 diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 982d0ec..5c5aa45 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -1,21 +1,12 @@ services: rag-api: - build: - context: .. - dockerfile: Apis/rag-api/Dockerfile + image: registry.easysoft.ro/apps/myai-rag-api:${IMAGE_TAG:-staging} container_name: myai-rag-api - ports: - - "8081:8080" - env_file: - - .env environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Database: matches rag-api appsettings Database section - Database__Host=${Database__Host:-sqlserver} - Database__Port=${Database__Port:-1433} - Database__Name=${Database__Name:-MyAiDb} @@ -23,11 +14,9 @@ services: - Database__Password=${Database__Password:-} - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - # InternalApi: matches rag-api appsettings InternalApi section - - InternalApi__ApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-false} + - InternalApi__ApiKey=${RagApi__InternalApiKey:-} + - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-true} - # Rag: matches rag-api appsettings Rag section - Rag__MaxFileSizeMb=${Rag__MaxFileSizeMb:-8} - Rag__ChunkSize=${Rag__ChunkSize:-900} - Rag__ChunkOverlap=${Rag__ChunkOverlap:-150} @@ -36,7 +25,6 @@ services: - Rag__MaxTopK=${Rag__MaxTopK:-50} - Rag__ClassifyWithAi=${Rag__ClassifyWithAi:-false} - # Ai: matches rag-api appsettings Ai section - Ai__Provider=${Ai__Provider:-OpenAI} - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} @@ -47,11 +35,6 @@ services: - Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text} - 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__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} @@ -60,8 +43,7 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Apis/api/logs:/app/logs - - ../Apis/api/Files:/app/Files + - ${LOGS_PATH:-/opt/myai/logs}/rag-api:/app/logs networks: - myai-network restart: unless-stopped @@ -69,24 +51,15 @@ services: - "com.centurylinklabs.watchtower.enable=true" cv-matcher-api: - build: - context: .. - dockerfile: Apis/cv-matcher-api/Dockerfile + image: registry.easysoft.ro/apps/myai-cv-matcher-api:${IMAGE_TAG:-staging} container_name: myai-cv-matcher-api depends_on: - rag-api - ports: - - "8082:8080" - env_file: - - .env environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Database: matches cv-matcher-api appsettings Database section - Database__Host=${Database__Host:-sqlserver} - Database__Port=${Database__Port:-1433} - Database__Name=${Database__Name:-MyAiDb} @@ -94,15 +67,12 @@ services: - Database__Password=${Database__Password:-} - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - # InternalApi: matches cv-matcher-api appsettings InternalApi section - - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-false} + - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-} + - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-true} - # RagApi: matches cv-matcher-api appsettings RagApi section - 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__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} @@ -111,16 +81,10 @@ services: - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - # Matcher: matches cv-matcher-api appsettings Matcher section - Matcher__TopK=${Matcher__TopK:-10} - Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5} - 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__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} @@ -129,7 +93,7 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Apis/api/logs:/app/logs + - ${LOGS_PATH:-/opt/myai/logs}/cv-matcher-api:/app/logs networks: - myai-network restart: unless-stopped @@ -137,64 +101,43 @@ services: - "com.centurylinklabs.watchtower.enable=true" api: - build: - context: .. - dockerfile: Apis/api/Dockerfile + image: registry.easysoft.ro/apps/myai-api:${IMAGE_TAG:-staging} container_name: myai-api depends_on: - cv-matcher-api - ports: - - "8080:8080" - env_file: - - .env - # Keep this only if Apis/api/.env contains api-specific overrides not present in docker-compose/.env. - # - ../Apis/api/.env environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Google: matches api appsettings Google section - Google__TagManagerId=${Google__TagManagerId:-} - Google__MapKey=${Google__MapKey:-} - # Contact / Subscribe: matches api appsettings Contact and Subscribe sections - Contact__ToEmail=${Contact__ToEmail:-} - - Contact__FromEmail=${Contact__FromEmail:-${Smtp__Username:-}} + - Contact__FromEmail=${Contact__FromEmail:-} - Contact__SubjectPrefix=${Contact__SubjectPrefix:-} - Subscribe__ToEmail=${Subscribe__ToEmail:-} - Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-} - # SMTP: matches api appsettings Smtp section - - Smtp__Host=${Smtp__Host:-mail.example.com} + - Smtp__Host=${Smtp__Host:-} - Smtp__Port=${Smtp__Port:-587} - Smtp__Username=${Smtp__Username:-} - Smtp__Password=${Smtp__Password:-} - Smtp__UseStartTls=${Smtp__UseStartTls:-false} - # Captcha: matches api appsettings Captcha section - Captcha__Provider=${Captcha__Provider:-Recaptcha} - Captcha__SecretKey=${Captcha__SecretKey:-} - Captcha__PublicKey=${Captcha__PublicKey:-} - Captcha__MinimumScore=${Captcha__MinimumScore:-0.5} - # FileStorage: matches api appsettings FileStorage section - FileStorage__Path=${FileStorage__Path:-Files} - 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__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} + - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-} - # JobSearch: base URL used to build the job search link in match emails - JobSearch__BaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro} - # Rate Limiting: matches api appsettings RateLimiting section - RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120} - RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00} - RateLimiting__Global__QueueLimit=${RateLimiting__Global__QueueLimit:-0} @@ -205,15 +148,9 @@ services: - RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00} - 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:-http://localhost:5000} - - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-http://web:8080} + - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-} + - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-} - # 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__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} @@ -222,8 +159,8 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Apis/api/logs:/app/logs - - ../Apis/api/Files:/app/Files + - ${LOGS_PATH:-/opt/myai/logs}/api:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files networks: - myai-network restart: unless-stopped @@ -231,36 +168,20 @@ services: - "com.centurylinklabs.watchtower.enable=true" cv-cleanup-job: - build: - context: .. - dockerfile: Jobs/cv-cleanup-job/Dockerfile + image: registry.easysoft.ro/apps/myai-cv-cleanup-job:${IMAGE_TAG:-staging} container_name: myai-cv-cleanup-job depends_on: - api - env_file: - - .env environment: - # Worker + diagnostics (matches Jobs/cv-cleanup-job appsettings) - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # FileStorage: matches cv-cleanup-job appsettings FileStorage section - FileStorage__Path=${FileStorage__Path:-Files} - # Jobs: matches cv-cleanup-job appsettings Jobs:Tasks - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} - 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__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} @@ -269,8 +190,8 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Jobs/cv-cleanup-job/logs:/app/logs - - ../Apis/api/Files:/app/Files + - ${LOGS_PATH:-/opt/myai/logs}/cv-cleanup-job:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files networks: - myai-network restart: unless-stopped @@ -278,21 +199,14 @@ services: - "com.centurylinklabs.watchtower.enable=true" cv-search-job: - build: - context: .. - dockerfile: Jobs/cv-search-job/Dockerfile + image: registry.easysoft.ro/apps/myai-cv-search-job:${IMAGE_TAG:-staging} container_name: myai-cv-search-job depends_on: - cv-matcher-api - env_file: - - .env environment: - # Worker + diagnostics - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Database - Database__Host=${Database__Host:-sqlserver} - Database__Port=${Database__Port:-1433} - Database__Name=${Database__Name:-MyAiDb} @@ -300,39 +214,29 @@ services: - Database__Password=${Database__Password:-} - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - # CvMatcherApi (internal) - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} + - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-} - # SMTP - Smtp__Host=${Smtp__Host:-} - Smtp__Port=${Smtp__Port:-587} - Smtp__Username=${Smtp__Username:-} - Smtp__Password=${Smtp__Password:-} - Smtp__UseStartTls=${Smtp__UseStartTls:-false} - # Contact - Contact__ToEmail=${Contact__ToEmail:-} - # FileStorage (shared volume path must match api container) - FileStorage__Path=${FileStorage__Path:-Files} - # JobSearch settings - JobSearch__Enabled=${JobSearch__Enabled:-true} - JobSearch__JobSearchLinkBaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro} - JobSearch__TokenExpiryDays=${JobSearch__TokenExpiryDays:-7} - JobSearch__MinMatchScore=${JobSearch__MinMatchScore:-15} - JobSearch__MaxJobsToMatch=${JobSearch__MaxJobsToMatch:-15} - # Job task schedule - Jobs__Tasks__0__TaskType=CvSearch - Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true} - Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30} - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__Hosting__Lifetime=${Logging__LogLevel__Microsoft__Hosting__Lifetime:-Information} - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} @@ -341,8 +245,8 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Jobs/cv-search-job/logs:/app/logs - - ../Apis/api/Files:/app/Files + - ${LOGS_PATH:-/opt/myai/logs}/cv-search-job:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files networks: - myai-network restart: unless-stopped @@ -350,22 +254,15 @@ services: - "com.centurylinklabs.watchtower.enable=true" web: - build: - context: .. - dockerfile: web/Dockerfile + image: registry.easysoft.ro/apps/myai-web:${IMAGE_TAG:-staging} container_name: myai-web depends_on: - api - ports: - - "5000:8080" - env_file: - - .env environment: - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Site: matches web appsettings Site section (Normal, UnderConstruction, Unavailable) - Site__Mode=${Site__Mode:-Normal} networks: - myai-network From 7bed001d8b6a400512df9303eb72efd766a0c087 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Fri, 22 May 2026 19:03:47 +0300 Subject: [PATCH 5/5] Add .claude/ to .gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code stores session files under .claude/ — these are local tooling artifacts and should not be tracked in the repository. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4d72645..b4be5a0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# Claude Code session files +.claude/ + # Environment Variables - DO NOT COMMIT *.env .env