using System.Net.Http.Headers; using System.Text; using System.Text.Json; using JobsMedical.Web.Models; namespace JobsMedical.Web.Services.Scraping; public record AiStructured( string? Kind, string? Role, string? City, string? District, string? ShiftType, string? EmploymentType, long? PayAmount, int? SharePercent, string? Title, string? FacilityName); /// An AI verdict on a raw listing. public record AiAuditResult(string Decision, int Confidence, string? Reason, AiStructured? Data) { public bool Approve => Decision.Equals("approve", StringComparison.OrdinalIgnoreCase); public bool Reject => Decision.Equals("reject", StringComparison.OrdinalIgnoreCase); } 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); } /// /// Calls any OpenAI-compatible chat-completions endpoint (self-hosted vLLM/Ollama, or an Iranian /// provider — OpenAI/Anthropic are blocked from Iran). The admin-set system prompt is the /// "framework" that tells the model how to approve/reject/structure. We ask for strict JSON and /// parse it. Any failure returns null so ingestion falls back to the rule-based path. /// public class OpenAiCompatibleAuditor : IAiAuditor { private readonly IHttpClientFactory _http; private readonly ILogger _log; public OpenAiCompatibleAuditor(IHttpClientFactory http, ILogger log) { _http = http; _log = log; } public async Task AuditAsync(string rawText, AppSetting s, CancellationToken ct = default) { if (!s.AiEnabled || string.IsNullOrWhiteSpace(s.AiEndpoint)) return null; try { var payload = new { model = string.IsNullOrWhiteSpace(s.AiModel) ? "gpt-4o-mini" : s.AiModel, temperature = 0, response_format = new { type = "json_object" }, messages = new object[] { new { role = "system", content = s.AiSystemPrompt }, new { role = "user", content = "آگهی خام:\n" + rawText + "\n\nفقط با JSON پاسخ بده." }, }, }; var client = _http.CreateClient("ai"); client.Timeout = TimeSpan.FromSeconds(30); 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); 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); } catch (Exception ex) { _log.LogWarning(ex, "AI audit failed — falling back to rule-based decision."); return null; } } private static AiAuditResult? ParseVerdict(string json) { // The content itself should be a JSON object; tolerate code fences. json = json.Trim().Trim('`'); var start = json.IndexOf('{'); var end = json.LastIndexOf('}'); if (start < 0 || end <= start) return null; json = json.Substring(start, end - start + 1); using var doc = JsonDocument.Parse(json); var r = doc.RootElement; string? S(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; int I(string k, int d) => r.TryGetProperty(k, out var v) && v.TryGetInt32(out var n) ? n : d; long? L(string k) => r.TryGetProperty(k, out var v) && v.TryGetInt64(out var n) ? n : null; int? NI(string k) => r.TryGetProperty(k, out var v) && v.TryGetInt32(out var n) ? n : null; var decision = (S("decision") ?? "review").ToLowerInvariant(); var data = new AiStructured(S("kind"), S("role"), S("city"), S("district"), S("shiftType"), S("employmentType"), L("payAmount"), NI("sharePercent"), S("title"), S("facilityName")); return new AiAuditResult(decision, Math.Clamp(I("confidence", 50), 0, 100), S("reason"), data); } }