236 lines
10 KiB
C#
236 lines
10 KiB
C#
using Mango.Nop.Core.Loggers;
|
|
using Nop.Services.Configuration;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
#nullable enable
|
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|
{
|
|
/// <summary>
|
|
/// 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ó.
|
|
/// </summary>
|
|
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<ZaiService> _logger;
|
|
|
|
public ZaiService(
|
|
ISettingService settingService,
|
|
HttpClient httpClient,
|
|
ILogger<ZaiService> logger)
|
|
{
|
|
_settingService = settingService;
|
|
_settings = _settingService.LoadSetting<FruitBankSettings>();
|
|
_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 ─────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// OCR elemzés nyilvánosan elérhető URL alapján (kép vagy PDF).
|
|
/// </summary>
|
|
/// <param name="fileUrl">Nyilvánosan elérhető HTTP(S) URL. Kép: max 10 MB, PDF: max 50 MB / 100 oldal.</param>
|
|
public async Task<ZaiOcrResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="stream">Kép vagy PDF stream.</param>
|
|
/// <param name="mimeType">MIME típus, pl. "image/jpeg", "application/pdf".</param>
|
|
public async Task<ZaiOcrResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="base64Data">Nyers base64 vagy teljes data URI.</param>
|
|
/// <param name="mimeType">MIME típus (csak nyers base64 esetén szükséges).</param>
|
|
public async Task<ZaiOcrResult> 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 ──────────────────
|
|
|
|
/// <summary>
|
|
/// Fájlkiterjesztés alapján visszaadja a megfelelő MIME típust.
|
|
/// </summary>
|
|
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<ZaiOcrResult> 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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A ZaiService által visszaadott OCR eredmény.
|
|
/// </summary>
|
|
public sealed class ZaiOcrResult
|
|
{
|
|
public bool IsSuccess { get; private init; }
|
|
|
|
/// <summary>
|
|
/// A teljes dokumentum Markdown+HTML vegyes formátumban.
|
|
/// Táblázatokat <table>/<th>/<td> tagek tartalmazzák — LLM promptba közvetlenül illeszthető.
|
|
/// </summary>
|
|
public string Markdown { get; private init; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// A nyers JSON válasz (diagnosztikához / layout_details feldolgozáshoz).
|
|
/// </summary>
|
|
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 };
|
|
}
|
|
}
|