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:
@@ -138,23 +138,41 @@ public class AppSetting
|
||||
: s.Split(new[] { '\n', '\r', ',', '،' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.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 = """
|
||||
تو دستیار بررسی آگهیهای کاری حوزه درمان برای پلتفرم «همکادر» هستی.
|
||||
هر آگهی خام را بخوان و تصمیم بگیر:
|
||||
- approve: آگهی واقعی و مرتبط با کادر درمان است و اطلاعات کافی دارد.
|
||||
- reject: تبلیغ، اسپم، نامرتبط، یا فاقد اطلاعات حداقلی است.
|
||||
- review: مرتبط است اما ناقص/مبهم و نیاز به بررسی انسانی دارد.
|
||||
سه نوع آگهی داریم:
|
||||
- shift: مرکز درمانی برای یک شیفت نیرو میخواهد.
|
||||
- job: مرکز درمانی برای استخدام دائم نیرو میخواهد.
|
||||
- talent: خودِ کادر درمان اعلام «آماده به کار / آماده همکاری» کرده است.
|
||||
نقش، شهر/محله، نوع شیفت/همکاری، مبلغ یا درصد سهم، عنوان، نام مرکز، و شماره تماس را در صورت وجود استخراج کن.
|
||||
برای talent: نام فرد، سال سابقه و پروانهدار بودن را هم استخراج کن.
|
||||
فقط با یک شیء 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)،
|
||||
sharePercent (0-100 یا null)، title، facilityName، phone،
|
||||
personName، yearsExperience (عدد یا null)، isLicensed (true|false).
|
||||
تو دستیار دستهبندی آگهیهای کادر درمان تهران برای پلتفرم «همکادر» هستی. هر ورودی یک متن خام از
|
||||
کانالهای تلگرام/بله/دیوار است. وظیفه: (۱) تشخیص نوع، (۲) استخراج دقیق فیلدها و دستهبندی فرد،
|
||||
(۳) تصمیم تأیید/رد/بررسی. فقط یک شیء JSON معتبر برگردان؛ هیچ متن اضافهای ننویس.
|
||||
|
||||
نوع (kind):
|
||||
• shift = مرکز درمانی برای بازهٔ زمانی مشخص نیرو میخواهد.
|
||||
• job = مرکز درمانی استخدام دائم/قراردادی دارد.
|
||||
• talent= فردی از کادر درمان خودش را «آماده به کار / آماده همکاری» معرفی میکند
|
||||
(سمت عرضه؛ مرکز ندارد و شماره تماسِ خودِ فرد مهمترین فیلد است).
|
||||
|
||||
نقش (role) و گروه (category):
|
||||
اول سعی کن نقش را با یکی از نقشهای رایج تطبیق دهی: پزشک عمومی، پزشک متخصص، پرستار،
|
||||
پرستار سالمندان، ماما، تکنسین اتاق عمل، تکنسین فوریتهای پزشکی، کارشناس آزمایشگاه، دندانپزشک.
|
||||
اگر تخصص دقیقاً در این فهرست نبود، عنوانِ دقیق و استانداردِ همان نقش را بنویس
|
||||
(مثل «پرستار ICU»، «کارشناس رادیولوژی»، «متخصص بیهوشی») — سیستم آن را بهعنوان نقش جدید
|
||||
ثبت و به همین فرد نسبت میدهد. عنوان را کوتاه و رسمی بنویس، نه جمله.
|
||||
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>
|
||||
<div class="filter-group">
|
||||
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework)</label>
|
||||
<textarea name="AiSystemPrompt" rows="8" dir="rtl">@Model.AiSystemPrompt</textarea>
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">به مدل بگو چطور تأیید/رد کند و چه فیلدهایی را استخراج کند. خروجی باید JSON باشد.</p>
|
||||
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework) — ثابت 🔒</label>
|
||||
<textarea rows="14" dir="rtl" readonly
|
||||
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>
|
||||
<label class="toggle-row">
|
||||
<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? AiApiKey { 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 AiUseProxy { get; set; }
|
||||
// Channel scraping sources
|
||||
@@ -82,7 +82,6 @@ public class SettingsModel : PageModel
|
||||
AiEndpoint = s.AiEndpoint;
|
||||
AiApiKey = s.AiApiKey;
|
||||
AiModel = s.AiModel;
|
||||
AiSystemPrompt = s.AiSystemPrompt;
|
||||
AiAutoApprove = s.AiAutoApprove;
|
||||
AiUseProxy = s.AiUseProxy;
|
||||
AutoIngestEnabled = s.AutoIngestEnabled;
|
||||
@@ -127,7 +126,7 @@ public class SettingsModel : PageModel
|
||||
AiEndpoint = AiEndpoint,
|
||||
AiApiKey = AiApiKey,
|
||||
AiModel = AiModel,
|
||||
AiSystemPrompt = AiSystemPrompt,
|
||||
// AiSystemPrompt intentionally omitted — AppSetting defaults it to DefaultPrompt (hardcoded).
|
||||
AiAutoApprove = AiAutoApprove,
|
||||
AiUseProxy = AiUseProxy,
|
||||
AutoIngestEnabled = AutoIngestEnabled,
|
||||
|
||||
@@ -9,7 +9,10 @@ namespace JobsMedical.Web.Services.Scraping;
|
||||
public record AiStructured(
|
||||
string? Kind, string? Role, string? City, string? District, string? ShiftType,
|
||||
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>
|
||||
public record AiAuditResult(string Decision, int Confidence, string? Reason, AiStructured? Data)
|
||||
@@ -45,7 +48,9 @@ public class OpenAiCompatibleAuditor : IAiAuditor
|
||||
confidence: عدد ۰ تا ۱۰۰
|
||||
reason: توضیح کوتاه فارسی
|
||||
kind: shift (شیفت توسط مرکز) | job (استخدام توسط مرکز) | talent (کادر درمان که خودش «آماده به کار» است)
|
||||
role: عنوان دقیق نقش درمانی (مثل پرستار، پزشک عمومی، دندانپزشک، تکنسین اتاق عمل، ماما، کارشناس آزمایشگاه)
|
||||
role: عنوان دقیق نقش درمانی (مثل پرستار، پزشک عمومی، دندانپزشک، تکنسین اتاق عمل، ماما، کارشناس آزمایشگاه). اگر تخصص دقیق در فهرست نبود، همان عنوان دقیق را برگردان.
|
||||
category: گروه نقش (پزشک | پرستار | ماما | تکنسین | دندانپزشک). اگر هیچکدام مناسب نبود، یک گروه کوتاه و مناسب پیشنهاد بده.
|
||||
tags: آرایهای از کلیدواژههای مهارت/الزام مرتبط بهصورت رشته (مثل "ICU"، "MMT"، "CPR"، "پروانهدار"، "خانم") یا []
|
||||
city, district: نام شهر و محله/منطقه در صورت ذکر
|
||||
shiftType: day|evening|night|oncall (فقط برای shift)
|
||||
employmentType: fulltime|parttime|contract|plan
|
||||
@@ -150,9 +155,9 @@ public class OpenAiCompatibleAuditor : IAiAuditor
|
||||
response_format = new { type = "json_object" },
|
||||
messages = new object[]
|
||||
{
|
||||
// Admin prompt + an authoritative output schema, so classification/tags stay
|
||||
// correct even if the stored prompt predates the talent/phone fields.
|
||||
new { role = "system", content = s.AiSystemPrompt + "\n\n" + OutputSchema },
|
||||
// Hardcoded, code-owned prompt (NOT the stored AiSystemPrompt) + the authoritative
|
||||
// output schema, so classification/tags can never be broken by an admin edit.
|
||||
new { role = "system", content = AppSetting.DefaultPrompt + "\n\n" + OutputSchema },
|
||||
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;
|
||||
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;
|
||||
// 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 data = new AiStructured(S("kind"), S("role"), S("city"), S("district"), S("shiftType"),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -31,8 +31,7 @@ public class SettingsService
|
||||
s.AiEndpoint = incoming.AiEndpoint?.Trim();
|
||||
s.AiApiKey = incoming.AiApiKey?.Trim();
|
||||
s.AiModel = incoming.AiModel?.Trim();
|
||||
s.AiSystemPrompt = string.IsNullOrWhiteSpace(incoming.AiSystemPrompt)
|
||||
? AppSetting.DefaultPrompt : incoming.AiSystemPrompt;
|
||||
s.AiSystemPrompt = AppSetting.DefaultPrompt; // hardcoded & read-only — keep the column in sync
|
||||
s.AiAutoApprove = incoming.AiAutoApprove;
|
||||
s.AiUseProxy = incoming.AiUseProxy;
|
||||
// Channel scraping sources
|
||||
|
||||
Reference in New Issue
Block a user