2026-06-03 17:41:02 +03:30
|
|
|
|
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;
|
|
|
|
|
|
|
2026-06-07 22:55:07 +03:30
|
|
|
|
/// <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;
|
|
|
|
|
|
|
2026-06-04 00:44:11 +03:30
|
|
|
|
// --- 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; }
|
|
|
|
|
|
|
2026-06-04 13:43:07 +03:30
|
|
|
|
/// <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; }
|
|
|
|
|
|
|
2026-06-04 17:53:17 +03:30
|
|
|
|
/// <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;
|
2026-06-04 18:46:48 +03:30
|
|
|
|
/// the sidecar converts that config into this local proxy. Per-source toggles below decide
|
|
|
|
|
|
/// which channels actually route through it.</summary>
|
2026-06-04 17:53:17 +03:30
|
|
|
|
[MaxLength(200)] public string? IngestProxyUrl { get; set; }
|
|
|
|
|
|
|
2026-06-04 18:46:48 +03:30
|
|
|
|
/// <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;
|
|
|
|
|
|
|
2026-06-04 00:44:11 +03:30
|
|
|
|
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; }
|
|
|
|
|
|
|
2026-06-04 06:12:10 +03:30
|
|
|
|
/// <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;
|
|
|
|
|
|
|
2026-06-04 10:27:21 +03:30
|
|
|
|
// --- 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; }
|
|
|
|
|
|
|
2026-06-04 10:47:33 +03:30
|
|
|
|
/// <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; }
|
|
|
|
|
|
|
2026-06-04 15:56:40 +03:30
|
|
|
|
// --- 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;
|
|
|
|
|
|
|
2026-06-04 11:23:13 +03:30
|
|
|
|
// --- 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";
|
|
|
|
|
|
|
2026-06-08 09:20:49 +03:30
|
|
|
|
// --- 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; }
|
|
|
|
|
|
|
2026-06-03 17:41:02 +03:30
|
|
|
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
|
|
|
|
|
|
2026-06-04 00:44:11 +03:30
|
|
|
|
/// <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();
|
|
|
|
|
|
|
2026-06-09 19:04:24 +03:30
|
|
|
|
/// <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>
|
2026-06-03 17:41:02 +03:30
|
|
|
|
public const string DefaultPrompt = """
|
2026-06-09 19:04:24 +03:30
|
|
|
|
تو دستیار دستهبندی آگهیهای کادر درمان تهران برای پلتفرم «همکادر» هستی. هر ورودی یک متن خام از
|
|
|
|
|
|
کانالهای تلگرام/بله/دیوار است. وظیفه: (۱) تشخیص نوع، (۲) استخراج دقیق فیلدها و دستهبندی فرد،
|
|
|
|
|
|
(۳) تصمیم تأیید/رد/بررسی. فقط یک شیء 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.
|
2026-06-03 17:41:02 +03:30
|
|
|
|
""";
|
|
|
|
|
|
}
|