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>
This commit is contained in:
soroush.asadi
2026-06-09 18:30:12 +03:30
parent 753a14286f
commit 59fb30ac77
3 changed files with 130 additions and 38 deletions
@@ -16,7 +16,11 @@
@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.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">
<div class="settings-layout">
@@ -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();
}
@@ -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
{
/// <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);
/// <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>
@@ -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<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
{
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;
/// <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)
{
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);
}
}
}