Compare commits

...

2 Commits

Author SHA1 Message Date
soroush.asadi cf5e0011c4 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>
2026-06-09 19:04:24 +03:30
soroush.asadi 59fb30ac77 AI auditor: surface the real connection error instead of swallowing it
The Test-AI button called AuditAsync, which caught every exception and returned
null, and used EnsureSuccessStatusCode() (discarding the response body). So a
failing AI service only ever produced a generic 'no response' message with no
detail — impossible to diagnose.

- Add IAiAuditor.TestAsync: runs the real call and returns a detailed Persian
  diagnostic — HTTP status + response body on non-2xx, raw body when the shape
  isn't OpenAI-compatible, and network/proxy/timeout specifics on exceptions.
- AuditAsync now logs the actual HTTP status + response body (and proxy state)
  instead of a bare warning, so server logs show why a call failed.
- ExtractContent / ParseVerdict no longer throw on unexpected JSON; they return
  null so the caller can show the raw body.
- Settings 'Test AI' button uses TestAsync; result box renders multi-line and
  switches to alert-error styling when the test fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:30:12 +03:30
6 changed files with 250 additions and 78 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.
"""; """;
} }
@@ -16,7 +16,11 @@
@if (Model.DemoMsg is not null) { <div class="alert alert-success">@Model.DemoMsg</div> } @if (Model.DemoMsg is not null) { <div class="alert alert-success">@Model.DemoMsg</div> }
@if (Model.SmsTest is not null) { <div class="alert alert-success">@Model.SmsTest</div> } @if (Model.SmsTest is not null) { <div class="alert alert-success">@Model.SmsTest</div> }
@if (Model.ProxyTest is not null) { <div class="alert alert-success">@Model.ProxyTest</div> } @if (Model.ProxyTest is not null) { <div class="alert alert-success">@Model.ProxyTest</div> }
@if (Model.AiTest is not null) { <div class="alert alert-success">@Model.AiTest</div> } @if (Model.AiTest is not null)
{
<div class="alert @(Model.AiTest.StartsWith("✅") ? "alert-success" : "alert-error")"
style="white-space:pre-wrap; word-break:break-word;">@Model.AiTest</div>
}
<form method="post"> <form method="post">
<div class="settings-layout"> <div class="settings-layout">
@@ -67,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,
@@ -212,14 +211,9 @@ public class SettingsModel : PageModel
{ AiTest = "ابتدا «فعال‌سازی هوش مصنوعی» را بزن و آدرس/کلید را ذخیره کن."; return RedirectToPage(); } { AiTest = "ابتدا «فعال‌سازی هوش مصنوعی» را بزن و آدرس/کلید را ذخیره کن."; return RedirectToPage(); }
const string sample = "استخدام پرستار خانم برای بخش اورژانس بیمارستان میلاد تهران، شیفت شب، حقوق توافقی، تماس ۰۹۱۲۱۲۳۴۵۶۷"; const string sample = "استخدام پرستار خانم برای بخش اورژانس بیمارستان میلاد تهران، شیفت شب، حقوق توافقی، تماس ۰۹۱۲۱۲۳۴۵۶۷";
try // TestAsync runs the real call and returns the exact reason on failure (HTTP status,
{ // response body, network/proxy error) — unlike AuditAsync, which swallows errors to null.
var r = await _ai.AuditAsync(sample, s); AiTest = await _ai.TestAsync(sample, s);
AiTest = r is null
? "❌ پاسخی از هوش مصنوعی دریافت نشد. کلید/آدرس و (در صورت نیاز) تیک «از طریق پروکسی» را بررسی کن."
: $"✅ هوش مصنوعی پاسخ داد — تصمیم: {r.Decision} | اطمینان: {r.Confidence}٪ | نقش: {r.Data?.Role} | شهر: {r.Data?.City} | شیفت: {r.Data?.ShiftType}";
}
catch (Exception ex) { AiTest = "❌ خطا در تماس با هوش مصنوعی: " + ex.Message; }
return RedirectToPage(); return RedirectToPage();
} }
@@ -1,3 +1,4 @@
using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@@ -8,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)
@@ -21,6 +25,11 @@ public interface IAiAuditor
{ {
/// <summary>Audit a raw post. Returns null when AI is off or the call fails (fail safe → manual).</summary> /// <summary>Audit a raw post. Returns null when AI is off or the call fails (fail safe → manual).</summary>
Task<AiAuditResult?> AuditAsync(string rawText, AppSetting settings, CancellationToken ct = default); Task<AiAuditResult?> AuditAsync(string rawText, AppSetting settings, CancellationToken ct = default);
/// <summary>Diagnostic: runs a real call and returns a detailed, human-readable Persian
/// success/error string (HTTP status, response snippet, exception detail) so the admin can
/// see exactly why the AI service won't connect. Never throws.</summary>
Task<string> TestAsync(string rawText, AppSetting settings, CancellationToken ct = default);
} }
/// <summary> /// <summary>
@@ -39,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
@@ -63,6 +74,79 @@ public class OpenAiCompatibleAuditor : IAiAuditor
if (!s.AiEnabled || string.IsNullOrWhiteSpace(s.AiEndpoint)) return null; if (!s.AiEnabled || string.IsNullOrWhiteSpace(s.AiEndpoint)) return null;
try try
{
var (status, body) = await SendAsync(rawText, s, ct);
if (!IsSuccess(status))
{
// Log the actual status + response body — the provider usually explains the failure
// here (bad key, unknown model, quota), so don't throw it away with EnsureSuccessStatusCode.
_log.LogWarning("AI endpoint {Endpoint} returned HTTP {Status}: {Body}",
s.AiEndpoint, (int)status, Truncate(body, 600));
return null;
}
var content = ExtractContent(body);
if (string.IsNullOrWhiteSpace(content))
{
_log.LogWarning("AI endpoint {Endpoint} returned no message content (response shape not OpenAI-compatible?). Body: {Body}",
s.AiEndpoint, Truncate(body, 600));
return null;
}
return ParseVerdict(content);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
_log.LogWarning("AI call to {Endpoint} timed out (proxy={Proxy}).", s.AiEndpoint, s.AiUseProxy);
return null;
}
catch (Exception ex)
{
_log.LogWarning(ex, "AI audit failed for endpoint {Endpoint} (proxy={Proxy}) — falling back to rule-based decision.",
s.AiEndpoint, s.AiUseProxy);
return null;
}
}
public async Task<string> TestAsync(string rawText, AppSetting s, CancellationToken ct = default)
{
if (!s.AiEnabled || string.IsNullOrWhiteSpace(s.AiEndpoint))
return "هوش مصنوعی غیرفعال است یا آدرس سرویس خالی است. ابتدا آن را فعال و ذخیره کن.";
try
{
var (status, body) = await SendAsync(rawText, s, ct);
if (!IsSuccess(status))
return $"❌ سرویس کد HTTP {(int)status} ({status}) برگرداند.\nآدرس: {s.AiEndpoint}\nپروکسی: {(s.AiUseProxy ? "روشن" : "خاموش")}\nپاسخ سرویس:\n{Truncate(body, 800)}";
var content = ExtractContent(body);
if (string.IsNullOrWhiteSpace(content))
return $"❌ پاسخ دریافت شد ولی محتوای پیام خالی بود — ساختار پاسخ با OpenAI سازگار نیست؟\nپاسخ خام:\n{Truncate(body, 800)}";
var v = ParseVerdict(content);
return v is null
? $"⚠️ مدل پاسخ داد ولی JSON قابل‌خواندن نبود. (response_format=json_object را پشتیبانی نمی‌کند؟)\nمحتوا:\n{Truncate(content, 800)}"
: $"✅ اتصال موفق — تصمیم: {v.Decision} | اطمینان: {v.Confidence}٪ | نقش: {v.Data?.Role} | شهر: {v.Data?.City} | شیفت: {v.Data?.ShiftType}";
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return "❌ مهلت پاسخ‌گویی تمام شد (timeout ۱۰۰ ثانیه). اگر تیک «از طریق پروکسی» روشن است، صحت آدرس پروکسی را بررسی کن.";
}
catch (HttpRequestException ex)
{
// DNS failure, connection refused, TLS error, proxy unreachable — the common Iran cases.
var inner = ex.InnerException is { } i ? $" — {i.Message}" : "";
return $"❌ خطای شبکه/پروکسی: {ex.Message}{inner}\nآدرس: {s.AiEndpoint}\nپروکسی: {(s.AiUseProxy ? "روشن" : "خاموش")}";
}
catch (Exception ex)
{
return $"❌ خطا: {ex.GetType().Name}: {ex.Message}";
}
}
/// <summary>POSTs the chat-completions request and returns the raw status + body. Shared by
/// AuditAsync (fail-safe) and TestAsync (diagnostic) so both exercise the identical call path.</summary>
private async Task<(HttpStatusCode status, string body)> SendAsync(string rawText, AppSetting s, CancellationToken ct)
{ {
var payload = new var payload = new
{ {
@@ -71,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 پاسخ بده." },
}, },
}; };
@@ -87,22 +171,31 @@ public class OpenAiCompatibleAuditor : IAiAuditor
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", s.AiApiKey); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", s.AiApiKey);
using var resp = await client.SendAsync(req, ct); using var resp = await client.SendAsync(req, ct);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync(ct); var body = await resp.Content.ReadAsStringAsync(ct);
return (resp.StatusCode, body);
using var doc = JsonDocument.Parse(body);
var content = doc.RootElement
.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString();
if (string.IsNullOrWhiteSpace(content)) return null;
return ParseVerdict(content);
} }
catch (Exception ex)
private static bool IsSuccess(HttpStatusCode s) => (int)s is >= 200 and < 300;
/// <summary>Pulls choices[0].message.content out of an OpenAI-style response. Returns null on any
/// unexpected shape (e.g. an error object) rather than throwing, so the caller can show the body.</summary>
private static string? ExtractContent(string body)
{ {
_log.LogWarning(ex, "AI audit failed — falling back to rule-based decision."); try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("choices", out var choices)
&& choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0
&& choices[0].TryGetProperty("message", out var msg)
&& msg.TryGetProperty("content", out var content))
return content.GetString();
}
catch (JsonException) { }
return null; return null;
} }
}
private static string Truncate(string? s, int max)
=> string.IsNullOrEmpty(s) ? "(خالی)" : (s.Length <= max ? s : s[..max] + " …");
private static AiAuditResult? ParseVerdict(string json) private static AiAuditResult? ParseVerdict(string json)
{ {
@@ -113,7 +206,11 @@ public class OpenAiCompatibleAuditor : IAiAuditor
if (start < 0 || end <= start) return null; if (start < 0 || end <= start) return null;
json = json.Substring(start, end - start + 1); json = json.Substring(start, end - start + 1);
using var doc = JsonDocument.Parse(json); JsonDocument doc;
try { doc = JsonDocument.Parse(json); }
catch (JsonException) { return null; } // model returned non-JSON content
using (doc)
{
var r = doc.RootElement; var r = doc.RootElement;
// Guard on ValueKind == Number first — TryGetInt32/64 THROW on null/string values // Guard on ValueKind == Number first — TryGetInt32/64 THROW on null/string values
// (the model often returns payAmount/sharePercent as null), which would fail the whole parse. // (the model often returns payAmount/sharePercent as null), which would fail the whole parse.
@@ -122,11 +219,24 @@ 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