AI ingestion: dynamic role/category creation + tags, hardcoded read-only prompt
- Unknown roles from the AI are now resolved-or-CREATED (Persian-normalized dedupe) instead of dropped/fallback; new role gets the AI's category, assigned to the applicant. - AI output gains category + tags; AI-detected skills/requirements (ICU, MMT, پروانهدار…) now fold into the applicant's searchable Tags. - System prompt is hardcoded in AppSetting.DefaultPrompt and used directly by the auditor; admin sees it read-only (cannot edit/break it). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -166,14 +166,21 @@ public class IngestionService
|
||||
|
||||
// One ad can name several roles («پرستار سالمند و کودک و همراه بیمار») — resolve them all
|
||||
// and publish one listing per role so each is browsable/filterable. Capped to avoid spam.
|
||||
var roleNames = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(d?.Role)) roleNames.Add(d!.Role!.Trim());
|
||||
roleNames.AddRange(parsed.RoleNames);
|
||||
if (parsed.RoleName is not null) roleNames.Add(parsed.RoleName);
|
||||
var pubRoles = roleNames
|
||||
.Select(n => roles.FirstOrDefault(r => r.Name == n))
|
||||
.Where(r => r is not null).Cast<Role>()
|
||||
.Distinct().Take(4).ToList();
|
||||
// The AI's role (+ its category) is the trusted, possibly-new one; parser names are already
|
||||
// canonical matches. Unknown roles are CREATED (dynamic taxonomy), not dropped.
|
||||
var candidates = new List<(string name, string? category)>();
|
||||
if (!string.IsNullOrWhiteSpace(d?.Role)) candidates.Add((d!.Role!.Trim(), d.Category));
|
||||
foreach (var n in parsed.RoleNames) candidates.Add((n, null));
|
||||
if (parsed.RoleName is not null) candidates.Add((parsed.RoleName, null));
|
||||
|
||||
var pubRoles = new List<Role>();
|
||||
foreach (var (name, category) in candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
var role = ResolveOrCreateRole(roles, name, category);
|
||||
if (!pubRoles.Contains(role)) pubRoles.Add(role);
|
||||
if (pubRoles.Count >= 4) break;
|
||||
}
|
||||
if (pubRoles.Count == 0) pubRoles.Add(roles.First());
|
||||
|
||||
var city = cities.FirstOrDefault(c => c.Name == cityName)
|
||||
@@ -205,7 +212,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),
|
||||
Tags = BuildTags(parsed, d, role, city),
|
||||
});
|
||||
raw.Status = RawListingStatus.Normalized;
|
||||
return;
|
||||
@@ -261,13 +268,52 @@ 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)
|
||||
/// <summary>Space-separated searchable tags: parsed cert/skill tags + AI-detected skills/requirements
|
||||
/// + this listing's role/category + city. Drives deep search and tag chips on the applicant card.</summary>
|
||||
private static string BuildTags(ParsedListing parsed, AiStructured? d, Role role, City city)
|
||||
{
|
||||
var tags = new List<string>(parsed.Tags) { role.Name, city.Name };
|
||||
var tags = new List<string>(parsed.Tags) { role.Name, role.Category, city.Name };
|
||||
if (d?.Tags is not null)
|
||||
tags.AddRange(d.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()));
|
||||
return string.Join(" ", tags.Where(t => !string.IsNullOrWhiteSpace(t)).Distinct());
|
||||
}
|
||||
|
||||
/// <summary>Find an existing role by Persian-normalized name; if none, create a new Role (dynamic
|
||||
/// taxonomy) using the AI's suggested category — reusing an existing category when one normalizes
|
||||
/// to the same text — and add it to the in-run list so later items reuse it instead of duplicating.</summary>
|
||||
private Role ResolveOrCreateRole(List<Role> roles, string name, string? category)
|
||||
{
|
||||
var norm = NormalizeFa(name);
|
||||
var match = roles.FirstOrDefault(r => NormalizeFa(r.Name) == norm);
|
||||
if (match is not null) return match;
|
||||
|
||||
var wantCat = string.IsNullOrWhiteSpace(category) ? "سایر" : category!.Trim();
|
||||
// Collapse onto an existing category that normalizes the same, so «تکنسین» != «تکنسين» doesn't fork.
|
||||
var existingCat = roles.Select(r => r.Category)
|
||||
.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c) && NormalizeFa(c) == NormalizeFa(wantCat));
|
||||
|
||||
var created = new Role
|
||||
{
|
||||
Name = Clamp(name.Trim(), 100), // respect Role.Name MaxLength(100)
|
||||
Category = Clamp(existingCat ?? wantCat, 50), // respect Role.Category MaxLength(50)
|
||||
IsActive = true,
|
||||
SortOrder = (roles.Count == 0 ? 0 : roles.Max(r => r.SortOrder)) + 1,
|
||||
};
|
||||
_db.Roles.Add(created);
|
||||
roles.Add(created); // reuse within this run (saved with the batch at end of source)
|
||||
_log.LogInformation("Ingestion introduced new role «{Role}» (category «{Category}») from AI.",
|
||||
created.Name, created.Category);
|
||||
return created;
|
||||
}
|
||||
|
||||
/// <summary>Normalize a Persian string for dedupe: unify Arabic/Persian ي→ی and ك→ک, drop ZWNJ,
|
||||
/// collapse whitespace, trim, lowercase (so Latin tags like "ICU"/"icu" also match).</summary>
|
||||
private static string NormalizeFa(string? s) => Regex.Replace(
|
||||
(s ?? "").Replace('ي', 'ی').Replace('ك', 'ک').Replace('', ' ').Trim(),
|
||||
@"\s+", " ").ToLowerInvariant();
|
||||
|
||||
private static string Clamp(string s, int max) => s.Length <= max ? s : s[..max].Trim();
|
||||
|
||||
/// <summary>Fresh ContactMethod rows for one talent listing (parser contacts + AI phone).</summary>
|
||||
private static List<ContactMethod> BuildContacts(AiStructured? d, ParsedListing parsed)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user