From 59fb30ac774110a743ab88b46429782d69d97107 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 9 Jun 2026 18:30:12 +0330 Subject: [PATCH] AI auditor: surface the real connection error instead of swallowing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Pages/Admin/Settings.cshtml | 6 +- .../Pages/Admin/Settings.cshtml.cs | 11 +- .../Services/Scraping/AiAuditor.cs | 151 ++++++++++++++---- 3 files changed, 130 insertions(+), 38 deletions(-) diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml index f4637d6..5ee0128 100644 --- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml +++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml @@ -16,7 +16,11 @@ @if (Model.DemoMsg is not null) {
@Model.DemoMsg
} @if (Model.SmsTest is not null) {
@Model.SmsTest
} @if (Model.ProxyTest is not null) {
@Model.ProxyTest
} - @if (Model.AiTest is not null) {
@Model.AiTest
} + @if (Model.AiTest is not null) + { +
@Model.AiTest
+ }
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); + } } }