diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
index 0db3091..3a893f8 100644
--- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
+++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
@@ -212,14 +212,9 @@ public class SettingsModel : PageModel
{ AiTest = "ابتدا «فعالسازی هوش مصنوعی» را بزن و آدرس/کلید را ذخیره کن."; return RedirectToPage(); }
const string sample = "استخدام پرستار خانم برای بخش اورژانس بیمارستان میلاد تهران، شیفت شب، حقوق توافقی، تماس ۰۹۱۲۱۲۳۴۵۶۷";
- try
- {
- var r = await _ai.AuditAsync(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; }
+ // 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.
+ AiTest = await _ai.TestAsync(sample, s);
return RedirectToPage();
}
diff --git a/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs b/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs
index 6a5aed7..8a386d3 100644
--- a/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs
+++ b/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs
@@ -1,3 +1,4 @@
+using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
@@ -21,6 +22,11 @@ public interface IAiAuditor
{
///
Audit a raw post. Returns null when AI is off or the call fails (fail safe → manual).
Task
AuditAsync(string rawText, AppSetting settings, CancellationToken ct = default);
+
+ /// 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.
+ Task TestAsync(string rawText, AppSetting settings, CancellationToken ct = default);
}
///
@@ -64,46 +70,128 @@ public class OpenAiCompatibleAuditor : IAiAuditor
try
{
- var payload = new
+ var (status, body) = await SendAsync(rawText, s, ct);
+ if (!IsSuccess(status))
{
- model = string.IsNullOrWhiteSpace(s.AiModel) ? "gpt-4o-mini" : s.AiModel,
- temperature = 0,
- 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 },
- new { role = "user", content = "آگهی خام:\n" + rawText + "\n\nفقط با JSON پاسخ بده." },
- },
- };
+ // 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 client = _clients.ForAi(s); // proxy-aware when AiUseProxy is on (e.g. OpenAI from Iran)
- using var req = new HttpRequestMessage(HttpMethod.Post, s.AiEndpoint)
+ var content = ExtractContent(body);
+ if (string.IsNullOrWhiteSpace(content))
{
- Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
- };
- if (!string.IsNullOrWhiteSpace(s.AiApiKey))
- req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", s.AiApiKey);
-
- using var resp = await client.SendAsync(req, ct);
- resp.EnsureSuccessStatusCode();
- var body = await resp.Content.ReadAsStringAsync(ct);
-
- using var doc = JsonDocument.Parse(body);
- var content = doc.RootElement
- .GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString();
- if (string.IsNullOrWhiteSpace(content)) return null;
+ _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 — falling back to rule-based decision.");
+ _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 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}";
+ }
+ }
+
+ /// 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.
+ private async Task<(HttpStatusCode status, string body)> SendAsync(string rawText, AppSetting s, CancellationToken ct)
+ {
+ var payload = new
+ {
+ model = string.IsNullOrWhiteSpace(s.AiModel) ? "gpt-4o-mini" : s.AiModel,
+ temperature = 0,
+ 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 },
+ new { role = "user", content = "آگهی خام:\n" + rawText + "\n\nفقط با JSON پاسخ بده." },
+ },
+ };
+
+ var client = _clients.ForAi(s); // proxy-aware when AiUseProxy is on (e.g. OpenAI from Iran)
+ using var req = new HttpRequestMessage(HttpMethod.Post, s.AiEndpoint)
+ {
+ Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
+ };
+ if (!string.IsNullOrWhiteSpace(s.AiApiKey))
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", s.AiApiKey);
+
+ using var resp = await client.SendAsync(req, ct);
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ return (resp.StatusCode, body);
+ }
+
+ private static bool IsSuccess(HttpStatusCode s) => (int)s is >= 200 and < 300;
+
+ /// 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.
+ private static string? ExtractContent(string body)
+ {
+ 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;
+ }
+
+ private static string Truncate(string? s, int max)
+ => string.IsNullOrEmpty(s) ? "(خالی)" : (s.Length <= max ? s : s[..max] + " …");
+
private static AiAuditResult? ParseVerdict(string json)
{
// The content itself should be a JSON object; tolerate code fences.
@@ -113,7 +201,11 @@ public class OpenAiCompatibleAuditor : IAiAuditor
if (start < 0 || end <= start) return null;
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;
// 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.
@@ -128,5 +220,6 @@ public class OpenAiCompatibleAuditor : IAiAuditor
S("employmentType"), L("payAmount"), NI("sharePercent"), S("title"), S("facilityName"),
Phone: S("phone"), PersonName: S("personName"), YearsExperience: NI("yearsExperience"), IsLicensed: B("isLicensed"));
return new AiAuditResult(decision, Math.Clamp(I("confidence", 50), 0, 100), S("reason"), data);
+ }
}
}