feat(cv-search-job): enrich diagnostics and add scan summary to results email
Build and Push Docker Images Staging / build (push) Successful in 24s

Add funnel-level logging to HtmlJobSearcher (total anchors found,
stage-1 href-filter count, stage-2 keyword-filter count) and warn
when the keyword list is empty. Log the full search URL and response
size to catch silent HTTP failures or bot-block pages.

In CvSearchJobTask, log keywords and active providers at session start,
per-provider URL counts after each scrape, and every scored URL with its
verdict (ACCEPTED / rejected) at Information level.

Add a scan summary block to the results email (both non-empty and
empty-results paths) showing the CV keywords used as chips and the
comma-separated list of providers scanned.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 11:00:04 +03:00
parent e14a6a0f69
commit af3a14c7ed
4 changed files with 99 additions and 20 deletions
@@ -35,12 +35,16 @@ public sealed class CvSearchEmailSender
/// <param name="toEmail">Primary recipient (the user who triggered the search).</param>
/// <param name="attachmentFileName">Relative filename of the CV PDF to attach, or <c>null</c>.</param>
/// <param name="results">Ranked list of job search results to include in the email body.</param>
/// <param name="keywords">CV keywords used to drive the job search.</param>
/// <param name="providerNames">Names of the providers that were scanned.</param>
/// <param name="language">Two-letter language code for template rendering.</param>
/// <param name="ct">Cancellation token.</param>
public async Task SendResultsAsync(
string toEmail,
string? attachmentFileName,
IReadOnlyList<JobSearchResultEntity> results,
IReadOnlyList<string> keywords,
IReadOnlyList<string> providerNames,
string language,
CancellationToken ct)
{
@@ -54,7 +58,7 @@ public sealed class CvSearchEmailSender
if (recipients.Count == 0) return;
var htmlBody = BuildBody(results, language);
var htmlBody = BuildBody(results, keywords, providerNames, language);
var subject = _emailTemplates.Render("email.search-results.subject", language,
("count", results.Count.ToString()));
@@ -81,11 +85,14 @@ public sealed class CvSearchEmailSender
/// <summary>
/// Renders the HTML email body from the results list.
/// Returns the empty-results template when no results are present.
/// Prepends a scan summary block showing the keywords and providers used.
/// </summary>
private string BuildBody(IReadOnlyList<JobSearchResultEntity> results, string language)
private string BuildBody(IReadOnlyList<JobSearchResultEntity> results, IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language)
{
var scanSummary = BuildScanSummary(keywords, providerNames);
if (results.Count == 0)
return _emailTemplates.Get("email.search-results.empty", language);
return scanSummary + _emailTemplates.Get("email.search-results.empty", language);
var items = new System.Text.StringBuilder();
for (int i = 0; i < results.Count; i++)
@@ -107,7 +114,29 @@ public sealed class CvSearchEmailSender
return _emailTemplates.Render("email.search-results.body", language,
("count", results.Count.ToString()),
("items", items.ToString()));
("items", scanSummary + items.ToString()));
}
/// <summary>
/// Builds the scan summary block showing the CV keywords and providers used for the search.
/// </summary>
private static string BuildScanSummary(IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames)
{
var keywordsHtml = keywords.Count > 0
? string.Join("", keywords.Select(k =>
$"<span style=\"display:inline-block;background:#e9ecef;border-radius:4px;padding:2px 8px;margin:2px;font-size:12px\">{k}</span>"))
: "<span style=\"color:#6c757d;font-size:12px;font-style:italic\">none detected</span>";
var providersText = providerNames.Count > 0
? string.Join(", ", providerNames)
: "none";
return $"""
<div style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:14px 16px;margin-bottom:18px;font-size:13px;color:#495057">
<div style="margin-bottom:8px"><strong>Keywords used:</strong>&nbsp;{keywordsHtml}</div>
<div><strong>Providers scanned:</strong>&nbsp;{providersText}</div>
</div>
""";
}
/// <summary>