using Mango.Nop.Core.Loggers; using Nop.Services.Configuration; using System.Text; using System.Text.Json; #nullable enable namespace Nop.Plugin.Misc.FruitBankPlugin.Services { /// /// Z.ai GLM-OCR service — szállítólevelek, rendelési dokumentumok strukturált szövegkinyerésére. /// Endpoint: POST https://api.z.ai/api/paas/v4/layout_parsing /// Konfiguráció: FruitBankSettings.ZaiApiKey (+ opcionális ZaiModel, default: "glm-ocr") /// /// Output formátum: Markdown + HTML vegyes szöveg (md_results mező). /// A táblázatokat <table>/<thead>/<td> tagekben adja vissza — LLM-nek közvetlenül átadható. /// public class ZaiService { private const string LayoutParsingEndpoint = "https://api.z.ai/api/paas/v4/layout_parsing"; private const string DefaultModel = "glm-ocr"; private readonly ISettingService _settingService; private readonly FruitBankSettings _settings; private readonly HttpClient _httpClient; private readonly ILogger _logger; public ZaiService( ISettingService settingService, HttpClient httpClient, ILogger logger) { _settingService = settingService; _settings = _settingService.LoadSetting(); _httpClient = httpClient; _logger = logger; } private string ApiKey => _settings.ZaiApiKey ?? throw new InvalidOperationException("ZAI API kulcs nincs konfigurálva (FruitBankSettings.ZaiApiKey)."); private string Model => string.IsNullOrWhiteSpace(_settings.ZaiModel) ? DefaultModel : _settings.ZaiModel; // ── Publikus API ───────────────────────────────────────────────────────────── /// /// OCR elemzés nyilvánosan elérhető URL alapján (kép vagy PDF). /// /// Nyilvánosan elérhető HTTP(S) URL. Kép: max 10 MB, PDF: max 50 MB / 100 oldal. public async Task AnalyzeUrlAsync(string fileUrl) { if (string.IsNullOrWhiteSpace(fileUrl)) return ZaiOcrResult.Failure("A fileUrl paraméter üres."); var body = JsonSerializer.Serialize(new { model = Model, file = fileUrl }); return await CallApiAsync(body); } /// /// OCR elemzés memóriából (Stream). /// A stream tartalma base64-re konvertálódik, majd data URI-ként kerül az API-hoz. /// /// Kép vagy PDF stream. /// MIME típus, pl. "image/jpeg", "application/pdf". public async Task AnalyzeStreamAsync(Stream stream, string mimeType) { if (stream == null || stream.Length == 0) return ZaiOcrResult.Failure("Az átadott stream üres."); byte[] bytes; using (var ms = new MemoryStream()) { await stream.CopyToAsync(ms); bytes = ms.ToArray(); } return await AnalyzeBase64Async(Convert.ToBase64String(bytes), mimeType); } /// /// OCR elemzés base64 kódolt adat alapján. /// Ha az adat még nem tartalmazza a "data:" prefixet, automatikusan data URI-vá alakítja. /// /// Nyers base64 vagy teljes data URI. /// MIME típus (csak nyers base64 esetén szükséges). public async Task AnalyzeBase64Async(string base64Data, string mimeType = "image/jpeg") { if (string.IsNullOrWhiteSpace(base64Data)) return ZaiOcrResult.Failure("A base64Data paraméter üres."); var dataUri = base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ? base64Data : $"data:{mimeType};base64,{base64Data}"; var body = JsonSerializer.Serialize(new { model = Model, file = dataUri }); return await CallApiAsync(body); } // ── Segédmetódus: MIME típus meghatározása fájlnév alapján ────────────────── /// /// Fájlkiterjesztés alapján visszaadja a megfelelő MIME típust. /// public static string GetMimeType(string fileName) { var ext = Path.GetExtension(fileName).ToLowerInvariant(); return ext switch { ".pdf" => "application/pdf", ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", ".tif" or ".tiff" => "image/tiff", _ => "application/octet-stream" }; } // ── Belső API hívás ────────────────────────────────────────────────────────── private async Task CallApiAsync(string jsonBody) { using var request = new HttpRequestMessage(HttpMethod.Post, LayoutParsingEndpoint); request.Headers.Add("Authorization", $"Bearer {ApiKey}"); request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); try { var response = await _httpClient.SendAsync(request); var responseBody = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { _logger.Error("ZAI API hiba {StatusCode}: {Body}", new Exception(responseBody)); return ZaiOcrResult.Failure($"API hiba {(int)response.StatusCode}: {responseBody}"); } using var doc = JsonDocument.Parse(responseBody); var root = doc.RootElement; var markdown = TryGetMarkdown(root); // Token statisztika logolása (debug szinten, hogy ne spammelje a logot) if (root.TryGetProperty("usage", out var usage)) { var prompt = usage.TryGetProperty("prompt_tokens", out var pt) ? pt.GetInt32() : 0; var completion = usage.TryGetProperty("completion_tokens", out var ct) ? ct.GetInt32() : 0; _logger.Debug("ZAI GLM-OCR token felhasználás: {Prompt} + {Completion} = {Total}" +$"{prompt}, {completion}, {prompt} + {completion}"); } if (string.IsNullOrEmpty(markdown)) { _logger.Warning("ZAI GLM-OCR: md_results mező üres. Raw válasz: {Body}", responseBody); return ZaiOcrResult.Failure("Az OCR eredmény üres (md_results mező hiányzik a válaszból)."); } _logger.Debug($"ZAI GLM-OCR sikeres, karakter { markdown.Length}"); return ZaiOcrResult.Success(markdown, responseBody); } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { _logger.Error("ZAI GLM-OCR időtúllépés", ex); return ZaiOcrResult.Failure("Időtúllépés: a GLM-OCR API nem válaszolt időben. Nagy PDF-eknél növeld a HttpClient timeout-ját."); } catch (Exception ex) { _logger.Error("ZAI GLM-OCR hívás kivétellel végződött", ex); return ZaiOcrResult.Failure($"Hálózati hiba: {ex.Message}"); } } /// /// Az md_results mezőt keresi elsőként (layout_parsing API), majd fallback-eket próbál /// a chat completion API formátumhoz — így a service toleráns az esetleges API verziókkal szemben. /// private static string TryGetMarkdown(JsonElement root) { // Elsődleges: layout_parsing endpoint saját mezője if (root.TryGetProperty("md_results", out var mdResults)) { var val = mdResults.GetString(); if (!string.IsNullOrEmpty(val)) return val; } // Fallback 1: chat completion stílusú choices tömb if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0 && choices[0].TryGetProperty("message", out var msg) && msg.TryGetProperty("content", out var content)) { var val = content.GetString(); if (!string.IsNullOrEmpty(val)) return val; } // Fallback 2: egyszerű result mező if (root.TryGetProperty("result", out var result)) { var val = result.GetString(); if (!string.IsNullOrEmpty(val)) return val; } return string.Empty; } } // ── Result record ──────────────────────────────────────────────────────────────── /// /// A ZaiService által visszaadott OCR eredmény. /// public sealed class ZaiOcrResult { public bool IsSuccess { get; private init; } /// /// A teljes dokumentum Markdown+HTML vegyes formátumban. /// Táblázatokat <table>/<th>/<td> tagek tartalmazzák — LLM promptba közvetlenül illeszthető. /// public string Markdown { get; private init; } = string.Empty; /// /// A nyers JSON válasz (diagnosztikához / layout_details feldolgozáshoz). /// public string? RawResponse { get; private init; } public string? ErrorMessage { get; private init; } public static ZaiOcrResult Success(string markdown, string raw) => new() { IsSuccess = true, Markdown = markdown, RawResponse = raw }; public static ZaiOcrResult Failure(string error) => new() { IsSuccess = false, ErrorMessage = error }; } }