Applicants: auto-tags + deep search w/ highlight; never delete (archive instead)
- Tags: parser extracts cert/skill keywords (mmt, ICU/CCU, دیالیز, اتاق عمل, اورژانس, مسئول فنی, پروانهدار…) + role + city into TalentListing.Tags (+ migration); shown as chips on cards. - Deep search on /Talent: «جستجوی عمیق» box does Postgres ILIKE across tags, description, person, area, role, city (every term must match); matches are highlighted with <mark> via SearchHighlight. - Never delete: ShiftStatus.Archived + the admin «بایگانی گروهی» action now ARCHIVES aggregated posts (hidden from site, kept in DB) and leaves the raw crawl rows intact — a permanent archive for future analytics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ public class ParsedListing
|
||||
public bool IsLicensed { get; set; } // پروانهدار
|
||||
public string? AreaNote { get; set; } // «فقط منطقه ۱»
|
||||
public List<ParsedContact> Contacts { get; set; } = new(); // phones, email, socials…
|
||||
public List<string> Tags { get; set; } = new(); // cert/skill keywords for search
|
||||
public List<string> Notes { get; set; } = new(); // what was/wasn't detected (shown to admin)
|
||||
}
|
||||
|
||||
@@ -170,6 +171,12 @@ public class HeuristicListingParser : IListingParser
|
||||
if (p.FacilityName is not null) p.Notes.Add($"مرکز: {p.FacilityName}");
|
||||
}
|
||||
|
||||
// --- Tags (certs/skills for deep search): mmt, icu, پروانهدار, اتاق عمل … ---
|
||||
p.Tags = ExtractTags(text);
|
||||
if (p.RoleNames.Count > 0) p.Tags.AddRange(p.RoleNames);
|
||||
if (p.IsLicensed && !p.Tags.Contains("پروانهدار")) p.Tags.Add("پروانهدار");
|
||||
p.Tags = p.Tags.Distinct().ToList();
|
||||
|
||||
// --- Contacts (phones, email, socials — one ad may have several) ---
|
||||
p.Contacts = ExtractContacts(raw ?? text);
|
||||
p.Phone = p.Contacts.FirstOrDefault(c => c.Type is ContactType.Mobile or ContactType.Phone)?.Value;
|
||||
@@ -355,6 +362,40 @@ public class HeuristicListingParser : IListingParser
|
||||
return list.Take(8).ToList();
|
||||
}
|
||||
|
||||
// Canonical tag → trigger words found in the post.
|
||||
private static readonly (string Tag, string[] Needles)[] TagDict =
|
||||
{
|
||||
("mmt", new[] { "mmt", "ام ام تی", "امامتی" }),
|
||||
("ICU", new[] { "icu", "آی سی یو", "آیسییو" }),
|
||||
("CCU", new[] { "ccu", "سی سی یو", "سیسییو" }),
|
||||
("NICU", new[] { "nicu", "ان آی سی یو", "نوزادان" }),
|
||||
("BLS", new[] { "bls" }),
|
||||
("ACLS", new[] { "acls" }),
|
||||
("دیالیز", new[] { "دیالیز" }),
|
||||
("اتاق عمل", new[] { "اتاق عمل", "اسکراب" }),
|
||||
("بیهوشی", new[] { "بیهوشی" }),
|
||||
("تریاژ", new[] { "تریاژ" }),
|
||||
("تزریقات", new[] { "تزریقات", "تزریق" }),
|
||||
("پانسمان", new[] { "پانسمان", "زخم" }),
|
||||
("سونوگرافی", new[] { "سونوگرافی" }),
|
||||
("رادیولوژی", new[] { "رادیولوژی" }),
|
||||
("اورژانس", new[] { "اورژانس", "فوریت" }),
|
||||
("مسئول فنی", new[] { "مسئول فنی" }),
|
||||
("طرح", new[] { "طرح" }),
|
||||
("سالمند", new[] { "سالمند" }),
|
||||
("کودک", new[] { "کودک", "اطفال" }),
|
||||
("همراه بیمار", new[] { "همراه بیمار" }),
|
||||
("پروانهدار", new[] { "پروانه" }),
|
||||
};
|
||||
|
||||
private static List<string> ExtractTags(string text)
|
||||
{
|
||||
var tags = new List<string>();
|
||||
foreach (var (tag, needles) in TagDict)
|
||||
if (ContainsAny(text, needles)) tags.Add(tag);
|
||||
return tags;
|
||||
}
|
||||
|
||||
private static string UrlHandle(string url)
|
||||
{
|
||||
var u = url.Split('?')[0].TrimEnd('/');
|
||||
|
||||
@@ -205,6 +205,7 @@ public class IngestionService
|
||||
Description = raw.RawText,
|
||||
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl,
|
||||
Contacts = BuildContacts(d, parsed), // fresh instances per listing
|
||||
Tags = BuildTags(parsed, role, city),
|
||||
});
|
||||
raw.Status = RawListingStatus.Normalized;
|
||||
return;
|
||||
@@ -260,6 +261,13 @@ public class IngestionService
|
||||
raw.Status = RawListingStatus.Normalized;
|
||||
}
|
||||
|
||||
/// <summary>Space-separated searchable tags: parsed cert/skill tags + this listing's role + city.</summary>
|
||||
private static string BuildTags(ParsedListing parsed, Role role, City city)
|
||||
{
|
||||
var tags = new List<string>(parsed.Tags) { role.Name, city.Name };
|
||||
return string.Join(" ", tags.Where(t => !string.IsNullOrWhiteSpace(t)).Distinct());
|
||||
}
|
||||
|
||||
/// <summary>Fresh ContactMethod rows for one talent listing (parser contacts + AI phone).</summary>
|
||||
private static List<ContactMethod> BuildContacts(AiStructured? d, ParsedListing parsed)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
/// <summary>Wraps query terms in <mark> for result highlighting (HTML-safe).</summary>
|
||||
public static class SearchHighlight
|
||||
{
|
||||
public static HtmlString Mark(string? text, string? query)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return HtmlString.Empty;
|
||||
var encoded = WebUtility.HtmlEncode(text);
|
||||
if (string.IsNullOrWhiteSpace(query)) return new HtmlString(encoded);
|
||||
|
||||
var terms = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(t => t.Length >= 2)
|
||||
.Select(t => Regex.Escape(WebUtility.HtmlEncode(t)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (terms.Count == 0) return new HtmlString(encoded);
|
||||
|
||||
var pattern = string.Join("|", terms);
|
||||
var marked = Regex.Replace(encoded, pattern, m => $"<mark>{m.Value}</mark>", RegexOptions.IgnoreCase);
|
||||
return new HtmlString(marked);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user