AI ingestion: dynamic role/category creation + tags, hardcoded read-only prompt
CI/CD / CI · dotnet build (push) Successful in 2m19s
CI/CD / Deploy · hamkadr (push) Successful in 2m12s

- 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:
soroush.asadi
2026-06-09 19:04:24 +03:30
parent 59fb30ac77
commit cf5e0011c4
6 changed files with 123 additions and 43 deletions
@@ -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)
{