44 Commits

Author SHA1 Message Date
claude eac5c40b84 Merge staging into production: drop the throwaway rebuild comment
Build and Push Docker Images / build (push) Successful in 29s
Build and Push Docker Images / smoke (push) Successful in 0s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-24 11:34:54 +00:00
claude bb37314f31 chore: drop the throwaway comment used to force a rebuild
Build and Push Docker Images / build (push) Successful in 35s
Build and Push Docker Images / smoke (push) Successful in 0s
It existed only to make the image content differ so a deploy could be
timed. A note about a one-off measurement does not belong in a build file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-24 11:31:23 +00:00
claude 7d80a73b34 Merge staging into production: Watchtower trigger now configured for myAi
Build and Push Docker Images / build (push) Successful in 28s
Build and Push Docker Images / smoke (push) Successful in 0s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-24 11:26:07 +00:00
claude 4708b04482 chore: note the first myAi deploy with the Watchtower trigger configured
Build and Push Docker Images / build (push) Successful in 38s
Build and Push Docker Images / smoke (push) Successful in 0s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-24 11:25:14 +00:00
claude 8d6888ab86 Merge staging into production: trigger Watchtower instead of waiting for its poll
Build and Push Docker Images / build (push) Successful in 13s
Build and Push Docker Images / smoke (push) Successful in 15s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-24 11:02:45 +00:00
claude 4e09f27e4d ci: trigger Watchtower instead of waiting for its poll
Build and Push Docker Images / build (push) Successful in 14s
Build and Push Docker Images / smoke (push) Successful in 0s
The poll is the fallback, not the mechanism. It is 30s on staging but 300s
on production, so a green run could sit five minutes ahead of the deploy it
claimed to have made -- the smoke job was absorbing that wait.

Copied from easyDent verbatim, including the soft failure: an unset secret
or an unreachable API logs and falls back to the poll rather than failing
the build. That matters right now because production has no Watchtower HTTP
API yet, so the production URL will do nothing until the infra stack there
is updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-24 11:01:35 +00:00
claude 642c671091 Merge staging into production: deploy health check follows redirects
Build and Push Docker Images / build (push) Successful in 14s
Build and Push Docker Images / smoke (push) Successful in 15s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-24 10:36:09 +00:00
claude 449e4d2df3 ci: the deploy health check must follow redirects
Build and Push Docker Images / build (push) Successful in 13s
Build and Push Docker Images / smoke (push) Successful in 15s
jecreativ.ro's first production deploy went red for behaving exactly as
configured: it runs in UnderConstruction mode, so `/` correctly answers 302
to the placeholder, and the check asserted a bare 200.

Now `-L` follows the redirect and the FINAL code is asserted. A redirect
means the app is up and routing, which is what this step is for; a real
failure (502, 500, refused) still reports.

The version gate itself was right throughout -- it read 1bafc14 from the
production host, including through the under-construction middleware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-24 10:34:16 +00:00
claude 8441d0632e Merge staging into production: deploy guard (invalid IMAGE_TAG fallback + version-stamped smoke)
Build and Push Docker Images / build (push) Successful in 14s
Build and Push Docker Images / smoke (push) Successful in 5m0s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-24 10:24:23 +00:00
claude dee6c5a205 ci: make the branch strategy enforced instead of merely intended
Build and Push Docker Images / build (push) Successful in 20s
Build and Push Docker Images / smoke (push) Successful in 30s
Same guard as the four site repos. myAi was the ONLY stack that kept its
environment variables through the 26 July migration, so it alone deployed
correctly all along -- but it shares the ${IMAGE_TAG:-staging} default that
made the others fail silently, so it gets the same treatment.

1. ${IMAGE_TAG:-staging} -> ${IMAGE_TAG:-IMAGE_TAG-NOT-SET} on all eight
   images: an unconfigured stack fails the pull rather than quietly becoming
   staging.

2. A smoke job that asks the deploy host what commit it serves, via a
   /version.json stamped into the web image at build time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-24 10:21:18 +00:00
claude c2f2f9bd5b Merge main into production: public phone number +40 774 885 011, CI clone via LAN DNS
Build and Push Docker Images / build (push) Successful in 13s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-23 17:39:36 +00:00
claude 951ccec541 content: update public phone number to +40 774 885 011
Build and Push Docker Images / build (push) Successful in 18m52s
Old number 0722 523 764 is no longer in service. Updated both the tel:
and wa.me links as well as the displayed text on the landing page and the
cv-matcher page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
2026-08-23 17:16:42 +00:00
claude 10bac5eb91 ci: clone via docker-git.easysoft.ro:3000 (LAN DNS; drop git.easysoft.ro:3000 dependency)
Build and Push Docker Images / build (push) Successful in 18s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:51:31 +03:00
claude 65ae4b42da myai: document MSSQL host as mssql.easysoft.ro (DNS name) in the env template
Deployed staging/prod envs now use Database__Host=mssql.easysoft.ro (LAN DNS -> the MSSQL
VM 10.0.0.240) instead of the raw IP, matching the infra DNS standardization. The
docker-compose default stays 'sqlserver' for local dev. (.env/.env.staging/.env.production
are gitignored; their deployed values were updated locally and apply on redeploy.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:01:06 +03:00
claude 5aaf848423 chore: remove stray CV.pdf test artifact from repo root (unused, 284KB)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:20:21 +03:00
claude 9a33dc019a Promote staging->production: BuildKit CI, Compact JSON logging, consolidated compose + latest app code
Build and Push Docker Images / build (push) Successful in 3m23s
2026-06-18 19:14:59 +03:00
claude 62654978af ci: re-enable BuildKit (runner now has buildx plugin)
Build and Push Docker Images / build (push) Successful in 43m53s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:09:35 +03:00
claude 7da084c174 ci: revert DOCKER_BUILDKIT (runner job env has no buildx plugin -> build failed)
Keep the cache win (no base-image nuke); back to the legacy builder which the job
context supports. BuildKit needs buildx installed in the runner before it can be used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:02:29 +03:00
claude 27f4cfe21e ci: enable BuildKit + let its GC manage the layer cache
DOCKER_BUILDKIT=1 (explicit) so the restore layer is cached across builds; drop the
explicit 'docker builder prune' (it was wiping that cache) and rely on BuildKit's own
GC + 86GB headroom. Cleanup keeps dangling-image removal only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:58:00 +03:00
claude 903fbcd143 ci: preserve Docker build cache (faster runner)
Stop wiping the layer cache + base images after every build (the host has 86GB free).
Keep base images (prune dangling only) and ~2 weeks of build cache, so the cache-friendly
Dockerfiles (COPY *.csproj + restore before source) actually benefit -> warm rebuilds skip
restore and base-image pulls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:36:59 +03:00
claude c5e1b7f687 ci: branch-driven deploys (staging/production branches), build the pushed commit
main = day-to-day work (no deploy). Merge into staging -> :staging, into
production -> :production; IMAGE_TAG = branch name. Also fixes the checkout to
build the PUSHED commit (git checkout $GITHUB_SHA) instead of always cloning
the default branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:29:07 +03:00
claude 9b33876c11 docs: replace template README with a proper one (intro, run, deploy, logging)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:04:23 +03:00
claude 2d5572725d docs: add Observability section to CLAUDE.md (Compact JSON logs->Loki); gitignore ACCESS.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 15:46:46 +03:00
claude da1f90449e Merge observability/compact-json: standardize Serilog to Compact JSON (Application/Environment/AppVersion enrichment) 2026-06-18 10:59:13 +03:00
claude 2192c3f4c5 Logs: Compact JSON + aligned enrichment in shared StartupExtensions
CompactJsonFormatter in both ConfigureJsonSerilog overloads; rename Service->Application,
EnvironmentName->Environment (keep AppVersion). Applies to all myAi services.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:03:55 +03:00
claude 492859f17f ci: prune images + build cache after build (prevent runner disk exhaustion)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:51:31 +03:00
claude 1920885835 Merge main into production: PR #55 — Fix CV keyword extraction prompt
Build and Push Docker Images Staging / build (push) Successful in 52s
2026-06-09 16:41:03 +03:00
claude a3567ce8e9 Merge pull request 'Fix CV keyword extraction — derive from candidate CV, not matched job' (#55) from feature/fix-keyword-extraction-prompt into main
Build and Push Docker Images Staging / build (push) Successful in 2m23s
Merge PR #55: Fix CV keyword extraction — derive from candidate CV, not matched job
2026-06-09 13:40:10 +00:00
claude b52ef8ddff Fix CV keyword extraction to reflect candidate identity, not matched job
The AI prompt now instructs the LLM to derive keywords entirely from the
candidate's CV (seniority level, primary role title, core technologies they
emphasize) rather than from the job description being matched. This ensures
the job-board search keywords used by cv-search-job represent who the
candidate actually is, not a mirror of the job they happened to match against.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:38:02 +03:00
claude eb83d28ed5 Merge staging into production: PR #54 — Fix hardcoded user-facing strings
Build and Push Docker Images Staging / build (push) Successful in 10m36s
2026-06-08 22:34:35 +03:00
claude d2b12e39ec Merge PR #54: Fix hardcoded user-facing strings (Issue #53)
Build and Push Docker Images Staging / build (push) Successful in 46s
2026-06-08 22:34:28 +03:00
claude 1e8758796e Fix hardcoded user-facing strings — localize email fallbacks, API errors, AI parse messages
- Frontend: update extractApiError to check body.code first via i18n 'error.<code>' keys;
  add en/ro translations for cv_file_missing, captcha_verification_failed, request_cancelled
- email-data migration: seed 6 fallback template keys (match N/A, subject label, unknown IP,
  job search results empty states for keywords/providers/location)
- EmailApiEmailSender: replace "N/A", "Job", "Unknown" literals with template lookups
- CvSearchEmailSender: replace "none detected", "none", "-" literals with template lookups
- cv-matcher-data migration: seed parse-error.summary and parse-error.recommendation in AiPrompts
- CvMatcherService: look up localized parse-error messages from AiPrompts before calling ParseResult

Closes #53

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:32:03 +03:00
claude b6d9aea3bc Merge branch 'main' into production
Build and Push Docker Images Staging / build (push) Successful in 1m5s
2026-06-08 22:10:26 +03:00
claude ef2793448a Fix: declare language before jobLabel in CvMatcherController
Build and Push Docker Images Staging / build (push) Successful in 8m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:10:22 +03:00
claude 2b9132a3a9 Merge branch 'main' into production
Build and Push Docker Images Staging / build (push) Failing after 14s
2026-06-08 22:08:32 +03:00
claude cbf06031e8 Merge pull request 'Fix language consistency in job search and match emails' (#52) from feature/language-consistency into main
Build and Push Docker Images Staging / build (push) Failing after 32s
Merge PR #52: Fix language consistency in job search and match emails
2026-06-08 19:08:28 +00:00
claude 90f540139a Fix language consistency in job search and match emails
1. Pass session.Language to MatchJobRequest in cv-search-job so the LLM
   uses the correct language-specific prompt for job titles and summaries.

2. Replace hardcoded "Manual job description" with a template-driven label
   (email.match.manual-job-label) seeded in English and Romanian, so the
   match email subject and Job row reflect the user's language.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:07:15 +03:00
claude e5bf56cc4d Merge branch 'main' into production
Build and Push Docker Images Staging / build (push) Successful in 42s
2026-06-08 21:48:24 +03:00
claude 71d5ac8e06 Remove environment name prefix from email subjects
Build and Push Docker Images Staging / build (push) Successful in 1m26s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:47:21 +03:00
claude c2082d6729 Suppress environment prefix in email subjects on Production
[ENV_NAME] prefix is now only prepended in non-production environments
(Development, Staging, etc.). Production emails get a clean subject line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:45:46 +03:00
claude 8f58708cd9 Revert "Suppress environment prefix in email subjects on Production"
Build and Push Docker Images Staging / build (push) Successful in 1m35s
This reverts commit 06dd0140d6.
2026-06-08 21:45:45 +03:00
claude 06dd0140d6 Suppress environment prefix in email subjects on Production
[ENV_NAME] prefix is now only prepended in non-production environments
(Development, Staging, etc.). Production emails get a clean subject line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:45:29 +03:00
claude 0aee7c4ed6 Changes
Build and Push Docker Images Staging / build (push) Successful in 45s
2026-06-08 21:31:35 +03:00
claude cd661fe613 Merge pull request 'Staging to Production' (#51) from main into production
Merge staging to production
2026-06-08 18:28:46 +00:00
30 changed files with 1015 additions and 65 deletions
+110 -6
View File
@@ -1,13 +1,19 @@
name: Build and Push Docker Images Staging
name: Build and Push Docker Images
# Branch-driven deploys — no yaml edits to switch environment:
# merge into `staging` -> tag :staging (staging Watchtower deploys)
# merge into `production` -> tag :production (production Watchtower deploys)
# `main` is the day-to-day work branch and deploys nothing.
on:
push:
branches:
- staging
- production
env:
GIT_HOST: git.easysoft.ro
GIT_HOST: docker-git.easysoft.ro
REGISTRY_HOST: registry.easysoft.ro
DOCKER_BUILDKIT: "1"
API_IMAGE: apps/myai-api
CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api
RAG_API_IMAGE: apps/myai-rag-api
@@ -16,18 +22,20 @@ env:
CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api
IMAGE_TAG: staging
IMAGE_TAG: ${{ github.ref_name }} # branch name == image tag (staging | production)
WEB_PORT: "5140" # host port the web container is published on
jobs:
build:
runs-on: host
steps:
- name: Checkout repository
- name: Checkout the pushed commit
env:
TOKEN: ${{ secrets.REPO_TOKEN }}
run: |
git clone "http://gelu:${TOKEN}@${GIT_HOST}:3000/${GITHUB_REPOSITORY}.git" .
git checkout "${{ github.sha }}"
- name: Login to registry
run: |
@@ -53,7 +61,8 @@ jobs:
- name: Build Web image
run: |
docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" .
docker build --build-arg GIT_SHA="${{ github.sha }}" \
-f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" .
- name: Build CV cleanup job image
run: |
@@ -97,4 +106,99 @@ jobs:
- name: Push Page Fetcher API image
run: |
docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}"
docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}"
# Watchtower's poll is the fallback, not the mechanism: 30s on staging but 300s on
# production, so without this a green run can sit five minutes ahead of the deploy it
# claims to have made. Copied from easyDent, including the soft failure -- an unset
# secret or an unreachable API must degrade to the poll, never fail the build.
- name: Trigger Watchtower redeploy
env:
URL_STAGING: ${{ secrets.WATCHTOWER_URL_STAGING }}
URL_PRODUCTION: ${{ secrets.WATCHTOWER_URL_PRODUCTION }}
TOKEN: ${{ secrets.WATCHTOWER_TOKEN }}
run: |
if [ "${IMAGE_TAG}" = "production" ]; then URL="${URL_PRODUCTION}"; else URL="${URL_STAGING}"; fi
if [ -n "${URL}" ] && [ -n "${TOKEN}" ]; then
echo "Triggering Watchtower at ${URL}"
curl -sf -m 30 -H "Authorization: Bearer ${TOKEN}" "${URL}" && echo " -> redeploy triggered" \
|| echo " -> trigger failed; Watchtower will still pick it up on the next poll"
else
echo "Watchtower push-trigger not configured (WATCHTOWER_* secrets unset); relying on the poll interval."
fi
- name: Reclaim disk space (keep recent build cache)
if: always()
run: |
docker image prune -f # dangling only (keep base images)
# Building and pushing an image proves nothing about what the host is running.
# Watchtower pulls asynchronously, and for a month it was pulling a tag nobody
# intended -- with every run green, because no step ever asked the deployed site
# what it was serving. This job asks.
#
# It polls the deploy host directly on the LAN rather than the public hostname:
# the runner sits inside the network, only easysoft.ro has a staging equivalent in
# public DNS, and going direct also takes Caddy and any CDN out of the answer.
smoke:
runs-on: host
needs: build
steps:
- name: Wait for the deploy host to serve this commit
run: |
case "${{ github.ref_name }}" in
staging) HOST=192.168.1.111 ;;
production) HOST=192.168.1.101 ;;
*) echo "::error::No deploy host mapped for '${{ github.ref_name }}'."; exit 1 ;;
esac
URL="http://${HOST}:${WEB_PORT}/version.json"
echo "Polling ${URL} for ${{ github.sha }}"
# 10 minutes: Watchtower's poke is fire-and-forget with a 30s fallback poll,
# and the container still has to start.
# ⚠️ Steps run under `bash -e -o pipefail`, so a polling loop has to be written
# defensively: the FIRST miss is the normal case, not an error.
# - `curl -sf | sed` fails the whole pipeline under pipefail while the old
# container is still up (404/connection refused), so `|| GOT=""` is required
# - `[ test ] && { ... }` returns non-zero when the test fails, which under -e
# aborts the step. Use `if`.
# Getting both wrong made the first run of this job fail in 20 seconds.
DEADLINE=$(( $(date +%s) + 600 ))
while :; do
GOT=$(curl -sf -m 15 "${URL}" 2>/dev/null | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') || GOT=""
if [ "${GOT}" = "${{ github.sha }}" ]; then
echo "Serving ${GOT}."
break
fi
if [ "$(date +%s)" -ge "${DEADLINE}" ]; then
echo "::error::Timed out after 10m. ${HOST} is serving '${GOT:-nothing}', wanted ${{ github.sha }}."
echo "Either Watchtower never pulled the new image, the container failed to"
echo "start, or the stack's IMAGE_TAG does not match this branch."
exit 1
fi
echo " still serving '${GOT:-nothing}' ..."
sleep 15
done
- name: Check the site actually answers
run: |
case "${{ github.ref_name }}" in
staging) HOST=192.168.1.111 ;;
production) HOST=192.168.1.101 ;;
esac
# `-L` follows redirects and we assert on the FINAL code, because a 302 from `/`
# is a healthy answer for a site running in UnderConstruction mode -- it means the
# app is up and routing. Asserting a bare 200 failed jecreativ.ro's first
# production deploy for doing exactly what it was configured to do.
#
# `|| CODE=000` for the same reason as above: curl exiting non-zero on a
# connection failure must produce a reportable code, not kill the step before
# it can say what went wrong. (`-s` without `-f` already tolerates 4xx/5xx.)
CODE=$(curl -sL -o /dev/null -w '%{http_code}' -m 20 "http://${HOST}:${WEB_PORT}/") || CODE=000
if [ "${CODE}" != "200" ]; then
echo "::error::Home page returned ${CODE}."
exit 1
fi
echo "Home page 200."
+3
View File
@@ -376,3 +376,6 @@ files/
/docker-compose/.env.production
/docker-compose/.env.staging
# local infra access notes (secrets) — never commit
ACCESS.md
+2 -3
View File
@@ -170,11 +170,10 @@ public sealed class CvMatcherController : ControllerBase
!string.IsNullOrWhiteSpace(request.JobDescription));
var res = await _cvApi.MatchJob(request, ct);
var attachmentPath = TryGetCachedCvPath(request.CvDocumentId);
var language = NormalizeLanguage(request.Language);
var jobLabel = !string.IsNullOrWhiteSpace(request.JobUrl)
? request.JobUrl
: "Manual job description";
var language = NormalizeLanguage(request.Language);
: _emailSender.GetManualJobLabel(language);
string? jobSearchLink = null;
if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId))
@@ -61,5 +61,11 @@ namespace Api.Services.Contracts
/// <param name="expiryDays">Number of days until the job-search link expires (shown in the footer copy).</param>
/// <returns>Rendered HTML body string.</returns>
string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7);
/// <summary>
/// Returns the localised label for a manually-entered job description (no URL provided).
/// </summary>
/// <param name="language">Two-letter language code.</param>
string GetManualJobLabel(string language);
}
}
+7 -4
View File
@@ -138,7 +138,7 @@ public sealed class EmailApiEmailSender : IEmailSender
</tr>
<tr style="background:#f8f9fa">
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">IP Address</td>
<td style="border:1px solid #dee2e6">{userIp ?? "Unknown"}</td>
<td style="border:1px solid #dee2e6">{userIp ?? _emailTemplates.Get("email.notification.unknown-ip", "en")}</td>
</tr>
</table>
""";
@@ -215,8 +215,8 @@ public sealed class EmailApiEmailSender : IEmailSender
// email.match.body is now stored as HTML in the database
var body = _emailTemplates.Render("email.match.body", language,
("cvDocumentId", cvDocumentId),
("jobLabel", jobLabel ?? "N/A"),
("jobUrl", result.JobUrl ?? "N/A"),
("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.fallback-na", language)),
("jobUrl", result.JobUrl ?? _emailTemplates.Get("email.match.fallback-na", language)),
("score", result.Score.ToString()),
("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
("strengths", strengths),
@@ -238,5 +238,8 @@ public sealed class EmailApiEmailSender : IEmailSender
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
_emailTemplates.Render("email.match.subject", language,
("score", score.ToString()),
("jobLabel", jobLabel ?? "Job"));
("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.subject-fallback-label", language)));
public string GetManualJobLabel(string language) =>
_emailTemplates.Get("email.match.manual-job-label", language);
}
@@ -141,7 +141,9 @@ public sealed class CvMatcherService : ICvMatcherService
""";
var json = await _ai.CreateChatCompletionAsync(systemPrompt, userPrompt, 0.2m, ct);
var result = ParseResult(json);
var errorSummary = await _aiPrompts.GetAsync("parse-error.summary", language, ct);
var errorRec = await _aiPrompts.GetAsync("parse-error.recommendation", language, ct);
var result = ParseResult(json, errorSummary, errorRec);
result.JobDocumentId = job.Id;
result.JobUrl = job.SourceUrl;
result.Cached = false;
@@ -153,7 +155,10 @@ public sealed class CvMatcherService : ICvMatcherService
/// Deserialises the LLM's JSON output into a <see cref="JobMatchResponse"/>.
/// Returns a safe fallback response instead of throwing when the JSON cannot be parsed.
/// </summary>
private static JobMatchResponse ParseResult(string json)
private static JobMatchResponse ParseResult(
string json,
string? errorSummary = null,
string? errorRec = null)
{
try
{
@@ -168,8 +173,8 @@ public sealed class CvMatcherService : ICvMatcherService
return new JobMatchResponse
{
Score = 0,
Summary = "The AI response could not be parsed as structured JSON.",
Recommendations = ["Inspect the raw model output and tune the scoring prompt."]
Summary = errorSummary ?? "The AI response could not be parsed as structured JSON.",
Recommendations = [errorRec ?? "Inspect the raw model output and tune the scoring prompt."]
};
}
@@ -0,0 +1,138 @@
// <auto-generated />
using System;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvMatcher.Data.Migrations
{
[DbContext(typeof(CvMatcherDbContext))]
[Migration("20260608193046_AddParseErrorPrompts")]
partial class AddParseErrorPrompts
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvMatcher")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("AiPrompts", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
.IsUnique();
b.ToTable("Results", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
{
b.Property<string>("CacheKey")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("ResponseText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Temperature")
.HasColumnType("decimal(4,2)");
b.HasKey("CacheKey");
b.ToTable("ChatCache", "cvMatcher");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,67 @@
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class AddParseErrorPrompts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
columns: ["Key", "Language", "Value", "Description"],
values: ["parse-error.summary", "en", "The AI response could not be parsed. Please try again.", "Summary shown in match email when the AI returns an unparseable response"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
columns: ["Key", "Language", "Value", "Description"],
values: ["parse-error.summary", "ro", "Răspunsul AI nu a putut fi interpretat. Vă rugăm să încercați din nou.", "Sumar afișat în emailul de potrivire când AI returnează un răspuns care nu poate fi interpretat"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
columns: ["Key", "Language", "Value", "Description"],
values: ["parse-error.recommendation", "en", "If the problem persists, try a different job link or description.", "Recommendation shown in match email when the AI returns an unparseable response"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
columns: ["Key", "Language", "Value", "Description"],
values: ["parse-error.recommendation", "ro", "Dacă problema persistă, încercați un alt link sau descriere de job.", "Recomandare afișată în emailul de potrivire când AI returnează un răspuns care nu poate fi interpretat"]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["parse-error.summary", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["parse-error.summary", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["parse-error.recommendation", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["parse-error.recommendation", "ro"]);
}
}
}
@@ -0,0 +1,138 @@
// <auto-generated />
using System;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvMatcher.Data.Migrations
{
[DbContext(typeof(CvMatcherDbContext))]
[Migration("20260609133623_FixKeywordExtractionPrompt")]
partial class FixKeywordExtractionPrompt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvMatcher")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("AiPrompts", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
.IsUnique();
b.ToTable("Results", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
{
b.Property<string>("CacheKey")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("ResponseText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Temperature")
.HasColumnType("decimal(4,2)");
b.HasKey("CacheKey");
b.ToTable("ChatCache", "cvMatcher");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,93 @@
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class FixKeywordExtractionPrompt : Migration
{
// Full prompt values — only the 'keywords' instruction changes vs. the previous migration.
// Stored in full so Down() can restore the previous version exactly.
private const string EnNew =
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\n" +
"JSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"City, Country\"}.\n" +
"For 'keywords': extract 2-4 job-board search terms that represent the candidate's professional identity as shown in their CV — their seniority level and primary role title (e.g. 'Software Architect', 'Engineering Manager', 'Senior .NET Developer') plus 1-2 core technologies they genuinely emphasize throughout the CV. Derive these entirely from the CV — do not use the job title or job technologies unless they independently match the candidate's actual positioning. Avoid generic terms like 'developer', 'engineer', 'cloud', or 'leadership'.\n" +
"For 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.";
private const string EnPrev =
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\n" +
"JSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"City, Country\"}.\n" +
"For 'keywords': extract 2-4 short, concrete terms a recruiter would search for on a job board — the candidate's primary role title and key technologies (e.g. 'Senior .NET Developer', 'C#', 'Azure'). Avoid abstract concepts like 'leadership', 'cloud', or 'microservices'.\n" +
"For 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.";
private const string RoNew =
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\n" +
"JSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"Oraș, Țară\"}.\n" +
"Pentru 'keywords': extrage 2-4 termeni de căutare pe site-uri de joburi care reprezintă identitatea profesională a candidatului conform CV-ului — nivelul de senioritate și titlul principal de rol (ex. 'Software Architect', 'Engineering Manager', 'Senior .NET Developer') și 1-2 tehnologii de bază pe care candidatul le evidențiază cu adevărat în CV. Derivă aceștia exclusiv din CV — nu folosi titlul jobului sau tehnologiile din job dacă nu corespund poziționării reale a candidatului. Evită termeni generici precum 'developer', 'engineer', 'cloud' sau 'leadership'.\n" +
"Pentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.";
private const string RoPrev =
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\n" +
"JSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"Oraș, Țară\"}.\n" +
"Pentru 'keywords': extrage 2-4 termeni scurți și concreți pe care un recrutor i-ar căuta pe un site de joburi — titlul principal al rolului și tehnologiile cheie (ex. 'Senior .NET Developer', 'C#', 'Azure'). Evită concepte abstracte precum 'leadership', 'cloud' sau 'microservicii'.\n" +
"Pentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.";
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Update English prompt: keywords must now be derived from the CV only,
// not influenced by the job description being matched against.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "en"],
columns: ["Value", "Description"],
values: [
EnNew,
"System prompt for CV-to-job matching in English. Keywords represent the candidate's CV identity (seniority + role + core tech), not the job being matched."
]);
// Update Romanian prompt: same improvement.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
RoNew,
"System prompt pentru potrivire CV-job în română. Cuvintele cheie reprezintă identitatea CV-ului candidatului (senioritate + rol + tehnologii cheie), nu jobul cu care se face potrivirea."
]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "en"],
columns: ["Value", "Description"],
values: [
EnPrev,
"System prompt for CV-to-job matching in English. Extracts job-board-friendly keywords (role title + key tech) and candidate location."
]);
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
RoPrev,
"System prompt pentru potrivire CV-job în limba română. Extrage cuvinte cheie prietenoase pentru site-uri de joburi (titlu rol + tehnologii cheie) și locația candidatului."
]);
}
}
}
@@ -58,7 +58,7 @@ public sealed class SmtpEmailDispatcher
if (!string.IsNullOrWhiteSpace(req.ReplyTo))
msg.ReplyTo.Add(MailboxAddress.Parse(req.ReplyTo));
msg.Subject = $"[{_environmentName}] {req.Subject}".Trim();
msg.Subject = req.Subject.Trim();
var shellStart = _templates.Get("email.html-shell.start", "*");
var shellEnd = _templates.Get("email.html-shell.end", "*");
@@ -0,0 +1,69 @@
// <auto-generated />
using System;
using Email.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Email.Data.Migrations
{
[DbContext(typeof(EmailDbContext))]
[Migration("20260608190611_AddManualJobLabelTemplate")]
partial class AddManualJobLabelTemplate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("email")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("OperatorCopy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("Templates", "email");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,43 @@
using Email.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Email.Data.Migrations
{
/// <inheritdoc />
public partial class AddManualJobLabelTemplate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.manual-job-label", "en", "Manual job description", "Label used in the match email subject and body when no job URL was provided"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.manual-job-label", "ro", "Descriere manuală a jobului", "Etichetă folosită în subiectul și corpul emailului când nu a fost furnizat un URL de job"]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.manual-job-label", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.manual-job-label", "ro"]);
}
}
}
@@ -0,0 +1,69 @@
// <auto-generated />
using System;
using Email.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Email.Data.Migrations
{
[DbContext(typeof(EmailDbContext))]
[Migration("20260608192938_AddFallbackStringTemplates")]
partial class AddFallbackStringTemplates
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("email")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("OperatorCopy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("Templates", "email");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,163 @@
using Email.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Email.Data.Migrations
{
/// <inheritdoc />
public partial class AddFallbackStringTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.fallback-na", "en", "N/A", "Fallback when a match email field (job label or URL) has no value"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.fallback-na", "ro", "N/A", "Fallback când un câmp al emailului de potrivire (etichetă job sau URL) nu are valoare"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.subject-fallback-label", "en", "Job", "Fallback job label used in match email subject when no specific label is available"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.subject-fallback-label", "ro", "Job", "Etichetă fallback pentru subiectul emailului de potrivire când nu există o etichetă specifică"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.notification.unknown-ip", "en", "Unknown", "Fallback IP address label in operator notification emails"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.notification.unknown-ip", "ro", "Necunoscut", "Etichetă fallback pentru adresa IP în emailurile de notificare operator"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.keywords-empty", "en", "none detected", "Text shown in job search results email when no CV keywords were extracted"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.keywords-empty", "ro", "niciunul detectat", "Text afișat în emailul cu rezultatele căutării când nu au fost extrase cuvinte cheie din CV"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.providers-empty", "en", "none", "Text shown in job search results email when no providers were searched"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.providers-empty", "ro", "niciunul", "Text afișat în emailul cu rezultatele căutării când nu au fost căutați furnizori"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.location-empty", "en", "-", "Fallback location display in job search results email scan summary"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.location-empty", "ro", "-", "Afișaj fallback pentru locație în sumarului de scanare al emailului cu rezultatele căutării"]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.fallback-na", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.fallback-na", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.subject-fallback-label", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.subject-fallback-label", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.notification.unknown-ip", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.notification.unknown-ip", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.keywords-empty", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.keywords-empty", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.providers-empty", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.providers-empty", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.location-empty", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.location-empty", "ro"]);
}
}
}
+5 -1
View File
@@ -34,11 +34,15 @@ This applies to both the staging and production repos as appropriate.
- .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)
- Serilog — Compact JSON logs to stdout (+ optional email sink); see Observability
- MailKit for SMTP (used exclusively in `email-api`)
- Docker Compose for local and production deployment
- Watchtower for automatic container updates in production
## Observability (central stack on monitoring host 10.0.0.156)
- **Logs**: every service uses `ConfigureJsonSerilog(ServiceName, appVersion)` (startup-helpers) → Serilog **Compact JSON** to stdout, enriched `Application`/`Environment`/`AppVersion`. The host's Grafana **Alloy** agent ships stdout → **Loki**; view/query in Grafana. No file sink; optional email sink only if `SerilogEmail:*` is configured.
- **No app metrics/traces** — these are simple/minimal services, so (unlike easyDent) they don't expose Prometheus metrics or OTLP traces. Container/host metrics still come from the host's cAdvisor/node_exporter.
## Project taxonomy
| Category | Naming | Contains | EF dependency |
BIN
View File
Binary file not shown.
+1
View File
@@ -23,6 +23,7 @@
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<!-- Swagger -->
+6 -8
View File
@@ -39,11 +39,10 @@ public static class StartupExtensions
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Service", serviceName)
.Enrich.WithProperty("Application", serviceName)
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName)
.Enrich.WithProperty("AppVersion", appVersion)
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
.WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter());
AddEmailSinkIfConfigured(configuration, context.Configuration, serviceName);
});
@@ -57,11 +56,10 @@ public static class StartupExtensions
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Service", serviceName)
.Enrich.WithProperty("Application", serviceName)
.Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
.Enrich.WithProperty("AppVersion", appVersion)
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
.WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter());
AddEmailSinkIfConfigured(configuration, builder.Configuration, serviceName);
});
@@ -17,6 +17,7 @@
<PackageReference Include="DotNetEnv" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Environment" />
<PackageReference Include="Serilog.Formatting.Compact" />
<PackageReference Include="Serilog.Sinks.Email" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Swashbuckle.AspNetCore" />
@@ -127,13 +127,15 @@ public sealed class CvSearchEmailSender
var keywordsHtml = keywords.Count > 0
? string.Join(" ", keywords.Select(k =>
$"<span style=\"display:inline-block;background:#e9ecef;padding:2px 8px;margin:2px 2px 2px 0;font-size:12px;\">{k}</span>"))
: "<span style=\"color:#6c757d;font-size:12px;font-style:italic;\">none detected</span>";
: $"<span style=\"color:#6c757d;font-size:12px;font-style:italic;\">{_emailTemplates.Get("email.search-results.keywords-empty", language)}</span>";
var providers = providerNames.Count > 0
? string.Join(", ", providerNames)
: "none";
: _emailTemplates.Get("email.search-results.providers-empty", language);
var locationDisplay = string.IsNullOrWhiteSpace(location) ? "-" : location;
var locationDisplay = string.IsNullOrWhiteSpace(location)
? _emailTemplates.Get("email.search-results.location-empty", language)
: location;
return _emailTemplates.Render("email.search-results.scan-summary", language,
("keywordsHtml", keywordsHtml),
+3 -1
View File
@@ -198,7 +198,9 @@ public sealed class CvSearchJobTask : IJobTask
// Pre-fetched text passed directly so cv-matcher-api skips re-fetching the page
JobDescription = jobText,
// User already gave GDPR consent when they clicked the one-time job search link
GdprConsent = true
GdprConsent = true,
// Propagate language so the LLM uses the correct language-specific prompt
Language = session.Language
};
var matchResult = await _matcherApi.MatchJobAsync(matchRequest, ct);
+23 -16
View File
@@ -1,20 +1,27 @@
# Introduction
TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project.
# myAi
# Getting Started
TODO: Guide users through getting your code up and running on their own system. In this section you can talk about:
1. Installation process
2. Software dependencies
3. Latest releases
4. API references
The **myai.ro** platform — a set of .NET microservices (CV matching, RAG, email, CV search, page
fetching, …) behind a web frontend + API. Part of the easySoft platform.
# Build and Test
TODO: Describe and show how to build your code and run the tests.
## Layout
Multiple services (`*-api`, `*-job`) + `web`, sharing a common bootstrap in
`startup-helpers/` (Serilog, Swagger, `.env`/Key Vault loading, middleware). See **CLAUDE.md**
for the full service map, dependency chain, and conventions.
# Contribute
TODO: Explain how other users and developers can contribute to make your code better.
## Run locally
```bash
docker compose up --build # or run individual services with: dotnet run --project <svc>
```
If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files:
- [ASP.NET Core](https://github.com/aspnet/Home)
- [Visual Studio Code](https://github.com/Microsoft/vscode)
- [Chakra Core](https://github.com/Microsoft/ChakraCore)
## Deploy
CI builds `registry.easysoft.ro/apps/myai-*:{staging,production}`; Watchtower rolls them out to
the **staging (`10.0.0.183`)** + **production (`10.0.0.248`)** Portainer stacks. Edge Caddy serves
**myai.ro** (prod) / **myai.easysoft.ro** (staging).
## Logging
Every service: `ConfigureJsonSerilog(name, version)` → Serilog **Compact JSON** to stdout → Grafana
**Alloy****Loki**. No app metrics/traces (simple services).
---
See **CLAUDE.md** for the detailed solution guide and **ACCESS.md** (local, gitignored) for
infrastructure access.
+4 -2
View File
@@ -49,8 +49,10 @@ Ai__Ollama__ChatModel=llama2
Ai__Ollama__EmbeddingModel=embedding-model
Ai__Ollama__TimeoutSeconds=30
# Database (shared) - maps to Database:Host etc. used by apps
Database__Host=sqlserver
# Database (shared) - maps to Database:Host etc. used by apps.
# Deployed (staging/prod) uses the LAN DNS name (resolves to the MSSQL VM 10.0.0.240);
# for local dev leave it unset to use the docker-compose 'sqlserver' service default.
Database__Host=mssql.easysoft.ro
Database__Port=1433
Database__Name=MyAiDb
Database__User=sa
+16 -8
View File
@@ -1,6 +1,14 @@
# ⚠️ The IMAGE_TAG fallback is a DELIBERATELY INVALID tag, not `staging`.
# On 2026-07-26 these stacks were recreated by hand and lost their environment
# variables. The old `${IMAGE_TAG:-staging}` then quietly resolved to `staging`, so the
# production host pulled staging images -- with no mail credentials and no recipient
# addresses -- and served them for a month. Nothing failed, because falling back to a
# real tag is indistinguishable from being configured. Now an unset IMAGE_TAG yields
# `IMAGE_TAG-NOT-SET`, the pull fails with "manifest not found", the running container
# is left untouched and the deploy goes red. Loud beats plausible.
services:
rag-api:
image: registry.easysoft.ro/apps/myai-rag-api:${IMAGE_TAG:-staging}
image: registry.easysoft.ro/apps/myai-rag-api:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
container_name: myai-rag-api
environment:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
@@ -50,7 +58,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
cv-matcher-api:
image: registry.easysoft.ro/apps/myai-cv-matcher-api:${IMAGE_TAG:-staging}
image: registry.easysoft.ro/apps/myai-cv-matcher-api:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
container_name: myai-cv-matcher-api
depends_on:
- rag-api
@@ -102,7 +110,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
email-api:
image: registry.easysoft.ro/apps/myai-email-api:${IMAGE_TAG:-staging}
image: registry.easysoft.ro/apps/myai-email-api:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
container_name: myai-email-api
environment:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
@@ -143,7 +151,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
api:
image: registry.easysoft.ro/apps/myai-api:${IMAGE_TAG:-staging}
image: registry.easysoft.ro/apps/myai-api:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
container_name: myai-api
depends_on:
- cv-matcher-api
@@ -217,7 +225,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
cv-cleanup-job:
image: registry.easysoft.ro/apps/myai-cv-cleanup-job:${IMAGE_TAG:-staging}
image: registry.easysoft.ro/apps/myai-cv-cleanup-job:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
container_name: myai-cv-cleanup-job
depends_on:
- api
@@ -247,7 +255,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
cv-search-job:
image: registry.easysoft.ro/apps/myai-cv-search-job:${IMAGE_TAG:-staging}
image: registry.easysoft.ro/apps/myai-cv-search-job:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
container_name: myai-cv-search-job
depends_on:
- cv-matcher-api
@@ -300,7 +308,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
page-fetcher-api:
image: registry.easysoft.ro/apps/myai-page-fetcher-api:${IMAGE_TAG:-staging}
image: registry.easysoft.ro/apps/myai-page-fetcher-api:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
container_name: myai-page-fetcher-api
environment:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
@@ -332,7 +340,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
web:
image: registry.easysoft.ro/apps/myai-web:${IMAGE_TAG:-staging}
image: registry.easysoft.ro/apps/myai-web:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
container_name: myai-web
depends_on:
- api
+9
View File
@@ -16,4 +16,13 @@ EXPOSE 8080
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
COPY --from=build /app/publish .
# Stamp the commit into the image so a deploy can be verified from the outside.
#
# Without this a smoke test can only ask "does the site return 200?" -- which it did
# throughout the month production was quietly serving staging images. A status check
# cannot tell one build from another; /version.json can, so the smoke job refuses to
# pass until the host is actually serving THIS commit.
ARG GIT_SHA=unknown
RUN mkdir -p wwwroot && printf '{"version":"%s"}' "$GIT_SHA" > wwwroot/version.json
ENTRYPOINT ["dotnet", "web.dll"]
+2 -2
View File
@@ -139,13 +139,13 @@
<div>
<span data-i18n="contact.phone">Phone</span>
<strong>
<a href="tel:+40722523764">+40 722-523-764</a>
<a href="tel:+40774885011">+40 774-885-011</a>
</strong>
</div>
<div>
<span>WhatsApp</span>
<strong>
<a href="https://wa.me/40722523764" target="_blank" rel="noreferrer">+40 722-523-764</a>
<a href="https://wa.me/40774885011" target="_blank" rel="noreferrer">+40 774-885-011</a>
</strong>
</div>
+2 -2
View File
@@ -126,13 +126,13 @@
<div>
<span data-i18n="contact.phone">Phone</span>
<strong>
<a href="tel:+40722523764">+40 722-523-764</a>
<a href="tel:+40774885011">+40 774-885-011</a>
</strong>
</div>
<div>
<span>WhatsApp</span>
<strong>
<a href="https://wa.me/40722523764" target="_blank" rel="noreferrer">+40 722-523-764</a>
<a href="https://wa.me/40774885011" target="_blank" rel="noreferrer">+40 774-885-011</a>
</strong>
</div>
</div>
+8 -2
View File
@@ -115,7 +115,10 @@
"cv.noItems": "No items yet.",
"cv.strengths": "Strengths",
"cv.gaps": "Gaps",
"cv.evidence": "Supporting CV excerpts"
"cv.evidence": "Supporting CV excerpts",
"error.cv_file_missing": "Missing CV PDF.",
"error.captcha_verification_failed": "Captcha verification failed.",
"error.request_cancelled": "Request was cancelled."
},
ro: {
"brand.subtitle": "prezentare inginerie AI",
@@ -224,7 +227,10 @@
"cv.noItems": "Niciun element.",
"cv.strengths": "Puncte forte",
"cv.gaps": "Lipsuri",
"cv.evidence": "Fragmente relevante din CV"
"cv.evidence": "Fragmente relevante din CV",
"error.cv_file_missing": "Fișierul CV PDF lipsește.",
"error.captcha_verification_failed": "Verificarea captcha a eșuat.",
"error.request_cancelled": "Cererea a fost anulată."
}
};
})();
+12 -2
View File
@@ -52,6 +52,7 @@ function isValidEmail(value) {
*
* Rules:
* - 429 (rate limit) → return rateLimitKey translation
* - 4xx with known error code → look up 'error.<code>' in i18n dictionary first
* - 4xx with error body → return server's error message (intentional feedback)
* - 5xx or no body → return fallbackKey translation
*
@@ -65,8 +66,17 @@ function extractApiError(body, status, fallbackKey, rateLimitKey) {
if (status === 429) {
return window.MyAi.t(rateLimitKey || 'form.rateLimited');
}
var msg = body && (body.error || body.Error || body.title);
return (status >= 400 && status < 500 && msg) ? msg : window.MyAi.t(fallbackKey);
if (status >= 400 && status < 500) {
// Prefer i18n translation keyed on the machine-readable error code
if (body && body.code) {
var codeKey = 'error.' + body.code;
var translated = window.MyAi.t(codeKey);
if (translated !== codeKey) return translated;
}
var msg = body && (body.error || body.Error || body.title);
if (msg) return msg;
}
return window.MyAi.t(fallbackKey);
}
// Expose helpers on window.MyAi for use by other scripts