Files
hamkadr/src/JobsMedical.Web/Models/AppSetting.cs
T
soroush.asadi cf5e0011c4
CI/CD / CI · dotnet build (push) Successful in 2m19s
CI/CD / Deploy · hamkadr (push) Successful in 2m12s
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>
2026-06-09 19:04:24 +03:30

179 lines
11 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>
/// Single-row (Id=1) platform settings the admin controls at runtime — chiefly the ingestion
/// automation policy and the optional AI audit layer. Kept in the DB (not appsettings) so it's
/// editable from the admin panel without a redeploy.
/// </summary>
public class AppSetting
{
public int Id { get; set; } = 1;
// --- Ingestion automation ---
public IngestionMode Mode { get; set; } = IngestionMode.Manual;
/// <summary>In Automatic mode WITHOUT AI, listings at/above this confidence auto-publish.</summary>
public int AutoPublishMinConfidence { get; set; } = 85;
// --- AI audit layer (optional) ---
public bool AiEnabled { get; set; } = false;
/// <summary>OpenAI-compatible chat-completions endpoint (self-hosted or Iranian provider).</summary>
[MaxLength(500)] public string? AiEndpoint { get; set; }
[MaxLength(200)] public string? AiApiKey { get; set; }
[MaxLength(120)] public string? AiModel { get; set; } = "gpt-4o-mini";
/// <summary>The prompt + "framework" the AI follows to approve / reject / structure a listing.</summary>
[MaxLength(4000)]
public string AiSystemPrompt { get; set; } = DefaultPrompt;
/// <summary>If AI approves AND Mode is Automatic, publish without human review.</summary>
public bool AiAutoApprove { get; set; } = false;
/// <summary>Route AI calls through the ingestion proxy (IngestProxyUrl) — needed when the AI
/// endpoint (e.g. api.openai.com) is blocked in Iran.</summary>
public bool AiUseProxy { get; set; } = false;
// --- Channel scraping sources (configured here, NOT in env) ---
/// <summary>Run the ingestion worker on a timer.</summary>
public bool AutoIngestEnabled { get; set; } = false;
public int IngestIntervalMinutes { get; set; } = 30;
public bool TelegramEnabled { get; set; } = false;
/// <summary>Public Telegram channel usernames, one per line or comma-separated.</summary>
[MaxLength(2000)] public string? TelegramChannels { get; set; }
public bool BaleEnabled { get; set; } = false;
[MaxLength(200)] public string? BaleBotToken { get; set; }
/// <summary>Demo mode — keep the sample Tehran board seeded/visible (for showcasing).</summary>
public bool DemoMode { get; set; } = false;
public bool WebsitesEnabled { get; set; } = false;
/// <summary>Generic web pages to scrape, one URL per line.</summary>
[MaxLength(4000)] public string? WebsiteUrls { get; set; }
/// <summary>Local proxy an Xray/V2Ray client sidecar exposes, e.g. socks5://xray:10808
/// (also accepts socks4:// or http://). The app cannot read vmess/vless/trojan directly;
/// the sidecar converts that config into this local proxy. Per-source toggles below decide
/// which channels actually route through it.</summary>
[MaxLength(200)] public string? IngestProxyUrl { get; set; }
/// <summary>Legacy global flag — kept for compatibility; per-source flags below now control routing.</summary>
public bool IngestProxyEnabled { get; set; } = false;
// Per-source: route this source's fetches through IngestProxyUrl (only when a URL is set).
public bool TelegramUseProxy { get; set; } = false;
public bool BaleUseProxy { get; set; } = false;
public bool DivarUseProxy { get; set; } = false;
public bool MedjobsUseProxy { get; set; } = false;
public bool WebsitesUseProxy { get; set; } = false;
public bool DivarEnabled { get; set; } = false;
[MaxLength(60)] public string? DivarCity { get; set; } = "tehran";
/// <summary>Divar search terms, one per line or comma-separated.</summary>
[MaxLength(2000)] public string? DivarQueries { get; set; }
/// <summary>Scrape medjobs.ir job ads (WordPress classifieds — crawled via its sitemaps).</summary>
public bool MedjobsEnabled { get; set; } = false;
/// <summary>Max ads to fetch per ingestion run (be polite; dedupe skips already-seen).</summary>
public int MedjobsMaxAds { get; set; } = 40;
// --- SMS OTP (Kavenegar). When off, the code is shown on screen (dev only). ---
public bool SmsEnabled { get; set; } = false;
[MaxLength(200)] public string? SmsApiKey { get; set; }
/// <summary>Kavenegar verify/lookup template name (preferred OTP method in Iran).</summary>
[MaxLength(100)] public string? SmsTemplate { get; set; }
/// <summary>Sender line for plain SMS fallback when no template is set.</summary>
[MaxLength(30)] public string? SmsSender { get; set; }
/// <summary>Neshan web map.js API key — enables the click-to-pick map on the facility form
/// (Google Maps is blocked in Iran). Empty → only the "my location" button is shown.</summary>
[MaxLength(200)] public string? NeshanMapKey { get; set; }
// --- Notification channels (master on/off, controlled from the admin panel) ---
/// <summary>Live in-app / web notifications (SSE bell + toast + local OS popup). Works in Iran
/// because it streams over our own origin — no external push service. On by default.</summary>
public bool WebNotificationsEnabled { get; set; } = true;
// --- Web Push (PWA notifications). VAPID keypair; generate once with the web-push tooling. ---
public bool PushEnabled { get; set; } = false;
[MaxLength(200)] public string? VapidPublicKey { get; set; }
[MaxLength(200)] public string? VapidPrivateKey { get; set; }
[MaxLength(120)] public string? VapidSubject { get; set; } = "mailto:admin@hamkadr.ir";
// --- Social auto-posting: a daily «کادر آماده به کار» digest to Telegram/Bale (text) + an
// Instagram caption/hashtags pack (you post the image manually). ---
public bool SocialEnabled { get; set; } = false;
/// <summary>How many digests to publish per day (evenly spaced).</summary>
public int SocialPostsPerDay { get; set; } = 3;
/// <summary>Lines added above/below the auto-generated body (your branding, links, etc.).</summary>
[MaxLength(1000)] public string? SocialHeader { get; set; }
[MaxLength(1000)] public string? SocialFooter { get; set; }
/// <summary>Route the bot calls through the ingestion proxy (Telegram is filtered in Iran).</summary>
public bool SocialUseProxy { get; set; } = true;
public bool SocialTelegramEnabled { get; set; } = false;
[MaxLength(200)] public string? SocialTelegramBotToken { get; set; }
/// <summary>Channel/chat to post to — «@channelusername» or a numeric chat id.</summary>
[MaxLength(120)] public string? SocialTelegramChatId { get; set; }
public bool SocialBaleEnabled { get; set; } = false;
[MaxLength(200)] public string? SocialBaleBotToken { get; set; }
[MaxLength(120)] public string? SocialBaleChatId { get; set; }
public bool SocialInstagramEnabled { get; set; } = false;
/// <summary>Extra hashtags appended to the generated Instagram caption (space/line separated).</summary>
[MaxLength(1000)] public string? InstagramHashtags { get; set; }
public DateTime? SocialLastPostedAt { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Split a textarea (newline/comma separated) into trimmed non-empty items.</summary>
public static List<string> SplitList(string? s) => string.IsNullOrWhiteSpace(s)
? new()
: 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 = """
تو دستیار دستهبندی آگهیهای کادر درمان تهران برای پلتفرم «همکادر» هستی. هر ورودی یک متن خام از
کانالهای تلگرام/بله/دیوار است. وظیفه: (۱) تشخیص نوع، (۲) استخراج دقیق فیلدها و دستهبندی فرد،
(۳) تصمیم تأیید/رد/بررسی. فقط یک شیء 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.
""";
}