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
+35 -17
View File
@@ -138,23 +138,41 @@ public class AppSetting
: s.Split(new[] { '\n', '\r', ',', '،' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) : s.Split(new[] { '\n', '\r', ',', '،' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList(); .ToList();
/// <summary>The fixed, code-owned system prompt the AI follows. It is hardcoded (shown read-only
/// in admin) so it can't drift or be broken by an edit. The authoritative output-key schema is
/// appended automatically by <c>OpenAiCompatibleAuditor</c>, so this text stays behavioral.</summary>
public const string DefaultPrompt = """ public const string DefaultPrompt = """
تو دستیار بررسی آگهیهای کاری حوزه درمان برای پلتفرم «همکادر» هستی. تو دستیار دستهبندی آگهیهای کادر درمان تهران برای پلتفرم «همکادر» هستی. هر ورودی یک متن خام از
هر آگهی خام را بخوان و تصمیم بگیر: کانالهای تلگرام/بله/دیوار است. وظیفه: (۱) تشخیص نوع، (۲) استخراج دقیق فیلدها و دستهبندی فرد،
- approve: آگهی واقعی و مرتبط با کادر درمان است و اطلاعات کافی دارد. (۳) تصمیم تأیید/رد/بررسی. فقط یک شیء JSON معتبر برگردان؛ هیچ متن اضافهای ننویس.
- reject: تبلیغ، اسپم، نامرتبط، یا فاقد اطلاعات حداقلی است.
- review: مرتبط است اما ناقص/مبهم و نیاز به بررسی انسانی دارد. نوع (kind):
سه نوع آگهی داریم: shift = مرکز درمانی برای بازهٔ زمانی مشخص نیرو میخواهد.
- shift: مرکز درمانی برای یک شیفت نیرو میخواهد. job = مرکز درمانی استخدام دائم/قراردادی دارد.
- job: مرکز درمانی برای استخدام دائم نیرو میخواهد. talent= فردی از کادر درمان خودش را «آماده به کار / آماده همکاری» معرفی میکند
- talent: خودِ کادر درمان اعلام «آماده به کار / آماده همکاری» کرده است. (سمت عرضه؛ مرکز ندارد و شماره تماسِ خودِ فرد مهمترین فیلد است).
نقش، شهر/محله، نوع شیفت/همکاری، مبلغ یا درصد سهم، عنوان، نام مرکز، و شماره تماس را در صورت وجود استخراج کن.
برای talent: نام فرد، سال سابقه و پروانهدار بودن را هم استخراج کن. نقش (role) و گروه (category):
فقط با یک شیء JSON پاسخ بده با کلیدهای: اول سعی کن نقش را با یکی از نقشهای رایج تطبیق دهی: پزشک عمومی، پزشک متخصص، پرستار،
decision (approve|reject|review)، confidence (0-100)، reason (فارسی کوتاه)، پرستار سالمندان، ماما، تکنسین اتاق عمل، تکنسین فوریتهای پزشکی، کارشناس آزمایشگاه، دندانپزشک.
kind (shift|job|talent)، role، city، district، shiftType (day|evening|night|oncall)، اگر تخصص دقیقاً در این فهرست نبود، عنوانِ دقیق و استانداردِ همان نقش را بنویس
employmentType (fulltime|parttime|contract|plan)، payAmount (عدد تومان یا null)، (مثل «پرستار ICU»، «کارشناس رادیولوژی»، «متخصص بیهوشی») سیستم آن را بهعنوان نقش جدید
sharePercent (0-100 یا null)، title، facilityName، phone، ثبت و به همین فرد نسبت میدهد. عنوان را کوتاه و رسمی بنویس، نه جمله.
personName، yearsExperience (عدد یا null)، isLicensed (true|false). category را گروهِ آن نقش بگذار (پزشک | پرستار | ماما | تکنسین | دندانپزشک)؛
اگر هیچکدام مناسب نبود، یک گروهِ کوتاهِ مناسب پیشنهاد بده.
مهارتها/الزامات (tags): هر مهارت، گواهی یا شرطِ کلیدی را بهصورت آرایهای از کلیدواژههای
کوتاه برگردان (مثل "ICU"، "MMT"، "CPR"، "دیالیز"، "پروانه‌دار"، "خانم"، "آقا"). اگر نبود [].
شهر (city): فقط نام شهر (مثل «تهران»). محله/منطقه را در district بگذار.
تصمیم (decision):
approve = آگهیِ واقعیِ مرتبط با کادر درمان تهران با اطلاعات کافی.
reject = اسپم/تبلیغ/نامرتبط/خارج از کادر درمانِ تهران.
review = مرتبط ولی مبهم/ناقص.
confidence را ۰ تا ۱۰۰ بده و reason را کوتاه و فارسی بنویس.
برای talent: personName، yearsExperience، isLicensed (پروانهدار) و phone (ارقام لاتین)
را در صورت ذکر پر کن. هر فیلدِ نامشخص = null.
"""; """;
} }
@@ -71,9 +71,10 @@
<div style="flex:1;"><label>نام مدل</label><input type="text" name="AiModel" value="@Model.AiModel" dir="ltr" /></div> <div style="flex:1;"><label>نام مدل</label><input type="text" name="AiModel" value="@Model.AiModel" dir="ltr" /></div>
</div> </div>
<div class="filter-group"> <div class="filter-group">
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework)</label> <label>دستور و چارچوب هوش مصنوعی (Prompt / Framework) — ثابت 🔒</label>
<textarea name="AiSystemPrompt" rows="8" dir="rtl">@Model.AiSystemPrompt</textarea> <textarea rows="14" dir="rtl" readonly
<p class="muted" style="font-size:12px; margin:4px 0 0;">به مدل بگو چطور تأیید/رد کند و چه فیلدهایی را استخراج کند. خروجی باید JSON باشد.</p> style="background:var(--bg); color:var(--muted); cursor:not-allowed;">@JobsMedical.Web.Models.AppSetting.DefaultPrompt</textarea>
<p class="muted" style="font-size:12px; margin:4px 0 0;">این دستور در کد ثابت شده و قابل ویرایش نیست تا دسته‌بندی و استخراج همیشه درست بماند. یک «اسکیمای خروجی JSON» هم به‌صورت خودکار به انتهای آن افزوده می‌شود.</p>
</div> </div>
<label class="toggle-row"> <label class="toggle-row">
<input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" /> <input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" />
@@ -32,7 +32,7 @@ public class SettingsModel : PageModel
[BindProperty] public string? AiEndpoint { get; set; } [BindProperty] public string? AiEndpoint { get; set; }
[BindProperty] public string? AiApiKey { get; set; } [BindProperty] public string? AiApiKey { get; set; }
[BindProperty] public string? AiModel { get; set; } [BindProperty] public string? AiModel { get; set; }
[BindProperty] public string AiSystemPrompt { get; set; } = ""; // AiSystemPrompt is hardcoded (AppSetting.DefaultPrompt) and shown read-only — not bound/editable.
[BindProperty] public bool AiAutoApprove { get; set; } [BindProperty] public bool AiAutoApprove { get; set; }
[BindProperty] public bool AiUseProxy { get; set; } [BindProperty] public bool AiUseProxy { get; set; }
// Channel scraping sources // Channel scraping sources
@@ -82,7 +82,6 @@ public class SettingsModel : PageModel
AiEndpoint = s.AiEndpoint; AiEndpoint = s.AiEndpoint;
AiApiKey = s.AiApiKey; AiApiKey = s.AiApiKey;
AiModel = s.AiModel; AiModel = s.AiModel;
AiSystemPrompt = s.AiSystemPrompt;
AiAutoApprove = s.AiAutoApprove; AiAutoApprove = s.AiAutoApprove;
AiUseProxy = s.AiUseProxy; AiUseProxy = s.AiUseProxy;
AutoIngestEnabled = s.AutoIngestEnabled; AutoIngestEnabled = s.AutoIngestEnabled;
@@ -127,7 +126,7 @@ public class SettingsModel : PageModel
AiEndpoint = AiEndpoint, AiEndpoint = AiEndpoint,
AiApiKey = AiApiKey, AiApiKey = AiApiKey,
AiModel = AiModel, AiModel = AiModel,
AiSystemPrompt = AiSystemPrompt, // AiSystemPrompt intentionally omitted — AppSetting defaults it to DefaultPrompt (hardcoded).
AiAutoApprove = AiAutoApprove, AiAutoApprove = AiAutoApprove,
AiUseProxy = AiUseProxy, AiUseProxy = AiUseProxy,
AutoIngestEnabled = AutoIngestEnabled, AutoIngestEnabled = AutoIngestEnabled,
@@ -9,7 +9,10 @@ namespace JobsMedical.Web.Services.Scraping;
public record AiStructured( public record AiStructured(
string? Kind, string? Role, string? City, string? District, string? ShiftType, string? Kind, string? Role, string? City, string? District, string? ShiftType,
string? EmploymentType, long? PayAmount, int? SharePercent, string? Title, string? FacilityName, string? EmploymentType, long? PayAmount, int? SharePercent, string? Title, string? FacilityName,
string? Phone = null, string? PersonName = null, int? YearsExperience = null, bool? IsLicensed = null); string? Phone = null, string? PersonName = null, int? YearsExperience = null, bool? IsLicensed = null,
// Dynamic taxonomy: the model may name a role/category outside the seeded set (ingestion
// resolves-or-creates it). Tags carry the post's skills/requirements (ICU, MMT, پروانه‌دار…).
string? Category = null, IReadOnlyList<string>? Tags = null);
/// <summary>An AI verdict on a raw listing.</summary> /// <summary>An AI verdict on a raw listing.</summary>
public record AiAuditResult(string Decision, int Confidence, string? Reason, AiStructured? Data) public record AiAuditResult(string Decision, int Confidence, string? Reason, AiStructured? Data)
@@ -45,7 +48,9 @@ public class OpenAiCompatibleAuditor : IAiAuditor
confidence: عدد ۰ تا ۱۰۰ confidence: عدد ۰ تا ۱۰۰
reason: توضیح کوتاه فارسی reason: توضیح کوتاه فارسی
kind: shift (شیفت توسط مرکز) | job (استخدام توسط مرکز) | talent (کادر درمان که خودش «آماده به کار» است) kind: shift (شیفت توسط مرکز) | job (استخدام توسط مرکز) | talent (کادر درمان که خودش «آماده به کار» است)
role: عنوان دقیق نقش درمانی (مثل پرستار، پزشک عمومی، دندانپزشک، تکنسین اتاق عمل، ماما، کارشناس آزمایشگاه) role: عنوان دقیق نقش درمانی (مثل پرستار، پزشک عمومی، دندانپزشک، تکنسین اتاق عمل، ماما، کارشناس آزمایشگاه). اگر تخصص دقیق در فهرست نبود، همان عنوان دقیق را برگردان.
category: گروه نقش (پزشک | پرستار | ماما | تکنسین | دندانپزشک). اگر هیچکدام مناسب نبود، یک گروه کوتاه و مناسب پیشنهاد بده.
tags: آرایهای از کلیدواژههای مهارت/الزام مرتبط بهصورت رشته (مثل "ICU"، "MMT"، "CPR"، "پروانه‌دار"، "خانم") یا []
city, district: نام شهر و محله/منطقه در صورت ذکر city, district: نام شهر و محله/منطقه در صورت ذکر
shiftType: day|evening|night|oncall (فقط برای shift) shiftType: day|evening|night|oncall (فقط برای shift)
employmentType: fulltime|parttime|contract|plan employmentType: fulltime|parttime|contract|plan
@@ -150,9 +155,9 @@ public class OpenAiCompatibleAuditor : IAiAuditor
response_format = new { type = "json_object" }, response_format = new { type = "json_object" },
messages = new object[] messages = new object[]
{ {
// Admin prompt + an authoritative output schema, so classification/tags stay // Hardcoded, code-owned prompt (NOT the stored AiSystemPrompt) + the authoritative
// correct even if the stored prompt predates the talent/phone fields. // output schema, so classification/tags can never be broken by an admin edit.
new { role = "system", content = s.AiSystemPrompt + "\n\n" + OutputSchema }, new { role = "system", content = AppSetting.DefaultPrompt + "\n\n" + OutputSchema },
new { role = "user", content = "آگهی خام:\n" + rawText + "\n\nفقط با JSON پاسخ بده." }, new { role = "user", content = "آگهی خام:\n" + rawText + "\n\nفقط با JSON پاسخ بده." },
}, },
}; };
@@ -214,11 +219,23 @@ public class OpenAiCompatibleAuditor : IAiAuditor
long? L(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n) ? n : null; long? L(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n) ? n : null;
int? NI(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : null; int? NI(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : null;
bool? B(string k) => r.TryGetProperty(k, out var v) && (v.ValueKind == JsonValueKind.True || v.ValueKind == JsonValueKind.False) ? v.GetBoolean() : null; bool? B(string k) => r.TryGetProperty(k, out var v) && (v.ValueKind == JsonValueKind.True || v.ValueKind == JsonValueKind.False) ? v.GetBoolean() : null;
// Array-of-strings reader (tolerates the model returning a single string instead of an array).
IReadOnlyList<string>? SA(string k)
{
if (!r.TryGetProperty(k, out var v)) return null;
var list = new List<string>();
if (v.ValueKind == JsonValueKind.Array)
foreach (var el in v.EnumerateArray())
if (el.ValueKind == JsonValueKind.String && el.GetString() is { Length: > 0 } s) list.Add(s);
else if (v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } one) list.Add(one);
return list.Count > 0 ? list : null;
}
var decision = (S("decision") ?? "review").ToLowerInvariant(); var decision = (S("decision") ?? "review").ToLowerInvariant();
var data = new AiStructured(S("kind"), S("role"), S("city"), S("district"), S("shiftType"), var data = new AiStructured(S("kind"), S("role"), S("city"), S("district"), S("shiftType"),
S("employmentType"), L("payAmount"), NI("sharePercent"), S("title"), S("facilityName"), S("employmentType"), L("payAmount"), NI("sharePercent"), S("title"), S("facilityName"),
Phone: S("phone"), PersonName: S("personName"), YearsExperience: NI("yearsExperience"), IsLicensed: B("isLicensed")); Phone: S("phone"), PersonName: S("personName"), YearsExperience: NI("yearsExperience"), IsLicensed: B("isLicensed"),
Category: S("category"), Tags: SA("tags"));
return new AiAuditResult(decision, Math.Clamp(I("confidence", 50), 0, 100), S("reason"), data); return new AiAuditResult(decision, Math.Clamp(I("confidence", 50), 0, 100), S("reason"), data);
} }
} }
@@ -166,14 +166,21 @@ public class IngestionService
// One ad can name several roles («پرستار سالمند و کودک و همراه بیمار») — resolve them all // One ad can name several roles («پرستار سالمند و کودک و همراه بیمار») — resolve them all
// and publish one listing per role so each is browsable/filterable. Capped to avoid spam. // and publish one listing per role so each is browsable/filterable. Capped to avoid spam.
var roleNames = new List<string>(); // The AI's role (+ its category) is the trusted, possibly-new one; parser names are already
if (!string.IsNullOrWhiteSpace(d?.Role)) roleNames.Add(d!.Role!.Trim()); // canonical matches. Unknown roles are CREATED (dynamic taxonomy), not dropped.
roleNames.AddRange(parsed.RoleNames); var candidates = new List<(string name, string? category)>();
if (parsed.RoleName is not null) roleNames.Add(parsed.RoleName); if (!string.IsNullOrWhiteSpace(d?.Role)) candidates.Add((d!.Role!.Trim(), d.Category));
var pubRoles = roleNames foreach (var n in parsed.RoleNames) candidates.Add((n, null));
.Select(n => roles.FirstOrDefault(r => r.Name == n)) if (parsed.RoleName is not null) candidates.Add((parsed.RoleName, null));
.Where(r => r is not null).Cast<Role>()
.Distinct().Take(4).ToList(); 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()); if (pubRoles.Count == 0) pubRoles.Add(roles.First());
var city = cities.FirstOrDefault(c => c.Name == cityName) var city = cities.FirstOrDefault(c => c.Name == cityName)
@@ -205,7 +212,7 @@ public class IngestionService
Description = raw.RawText, Description = raw.RawText,
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl,
Contacts = BuildContacts(d, parsed), // fresh instances per listing Contacts = BuildContacts(d, parsed), // fresh instances per listing
Tags = BuildTags(parsed, role, city), Tags = BuildTags(parsed, d, role, city),
}); });
raw.Status = RawListingStatus.Normalized; raw.Status = RawListingStatus.Normalized;
return; return;
@@ -261,13 +268,52 @@ public class IngestionService
raw.Status = RawListingStatus.Normalized; raw.Status = RawListingStatus.Normalized;
} }
/// <summary>Space-separated searchable tags: parsed cert/skill tags + this listing's role + city.</summary> /// <summary>Space-separated searchable tags: parsed cert/skill tags + AI-detected skills/requirements
private static string BuildTags(ParsedListing parsed, Role role, City city) /// + 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()); 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> /// <summary>Fresh ContactMethod rows for one talent listing (parser contacts + AI phone).</summary>
private static List<ContactMethod> BuildContacts(AiStructured? d, ParsedListing parsed) private static List<ContactMethod> BuildContacts(AiStructured? d, ParsedListing parsed)
{ {
@@ -31,8 +31,7 @@ public class SettingsService
s.AiEndpoint = incoming.AiEndpoint?.Trim(); s.AiEndpoint = incoming.AiEndpoint?.Trim();
s.AiApiKey = incoming.AiApiKey?.Trim(); s.AiApiKey = incoming.AiApiKey?.Trim();
s.AiModel = incoming.AiModel?.Trim(); s.AiModel = incoming.AiModel?.Trim();
s.AiSystemPrompt = string.IsNullOrWhiteSpace(incoming.AiSystemPrompt) s.AiSystemPrompt = AppSetting.DefaultPrompt; // hardcoded & read-only — keep the column in sync
? AppSetting.DefaultPrompt : incoming.AiSystemPrompt;
s.AiAutoApprove = incoming.AiAutoApprove; s.AiAutoApprove = incoming.AiAutoApprove;
s.AiUseProxy = incoming.AiUseProxy; s.AiUseProxy = incoming.AiUseProxy;
// Channel scraping sources // Channel scraping sources