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:
@@ -16,7 +16,11 @@
|
|||||||
@if (Model.DemoMsg is not null) { <div class="alert alert-success">@Model.DemoMsg</div> }
|
@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.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.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">
|
<form method="post">
|
||||||
<div class="settings-layout">
|
<div class="settings-layout">
|
||||||
|
|||||||
@@ -212,14 +212,9 @@ public class SettingsModel : PageModel
|
|||||||
{ AiTest = "ابتدا «فعالسازی هوش مصنوعی» را بزن و آدرس/کلید را ذخیره کن."; return RedirectToPage(); }
|
{ AiTest = "ابتدا «فعالسازی هوش مصنوعی» را بزن و آدرس/کلید را ذخیره کن."; return RedirectToPage(); }
|
||||||
|
|
||||||
const string sample = "استخدام پرستار خانم برای بخش اورژانس بیمارستان میلاد تهران، شیفت شب، حقوق توافقی، تماس ۰۹۱۲۱۲۳۴۵۶۷";
|
const string sample = "استخدام پرستار خانم برای بخش اورژانس بیمارستان میلاد تهران، شیفت شب، حقوق توافقی، تماس ۰۹۱۲۱۲۳۴۵۶۷";
|
||||||
try
|
// 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.
|
||||||
var r = await _ai.AuditAsync(sample, s);
|
AiTest = await _ai.TestAsync(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; }
|
|
||||||
return RedirectToPage();
|
return RedirectToPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
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>
|
/// <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);
|
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>
|
/// <summary>
|
||||||
@@ -64,46 +70,128 @@ public class OpenAiCompatibleAuditor : IAiAuditor
|
|||||||
|
|
||||||
try
|
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,
|
// Log the actual status + response body — the provider usually explains the failure
|
||||||
temperature = 0,
|
// here (bad key, unknown model, quota), so don't throw it away with EnsureSuccessStatusCode.
|
||||||
response_format = new { type = "json_object" },
|
_log.LogWarning("AI endpoint {Endpoint} returned HTTP {Status}: {Body}",
|
||||||
messages = new object[]
|
s.AiEndpoint, (int)status, Truncate(body, 600));
|
||||||
{
|
return null;
|
||||||
// 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)
|
var content = ExtractContent(body);
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Post, s.AiEndpoint)
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
{
|
{
|
||||||
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
|
_log.LogWarning("AI endpoint {Endpoint} returned no message content (response shape not OpenAI-compatible?). Body: {Body}",
|
||||||
};
|
s.AiEndpoint, Truncate(body, 600));
|
||||||
if (!string.IsNullOrWhiteSpace(s.AiApiKey))
|
return null;
|
||||||
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;
|
|
||||||
|
|
||||||
return ParseVerdict(content);
|
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)
|
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;
|
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)
|
private static AiAuditResult? ParseVerdict(string json)
|
||||||
{
|
{
|
||||||
// The content itself should be a JSON object; tolerate code fences.
|
// 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;
|
if (start < 0 || end <= start) return null;
|
||||||
json = json.Substring(start, end - start + 1);
|
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;
|
var r = doc.RootElement;
|
||||||
// Guard on ValueKind == Number first — TryGetInt32/64 THROW on null/string values
|
// 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.
|
// (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"),
|
S("employmentType"), L("payAmount"), NI("sharePercent"), S("title"), S("facilityName"),
|
||||||
Phone: S("phone"), PersonName: S("personName"), YearsExperience: NI("yearsExperience"), IsLicensed: B("isLicensed"));
|
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);
|
return new AiAuditResult(decision, Math.Clamp(I("confidence", 50), 0, 100), S("reason"), data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user