👁️ استخراج محتوای جهانی با Qwen3.5-9B Vision

یکپارچه‌سازی قابلیت OCR هوشمند در معماری XFileContentExtractor
👨‍💻 توسعه‌دهنده: هادی خزاعی اصل
🏢 شرکت: فن آوران ساحر علم
📅 تاریخ: شنبه ۱۲ مهر ۱۴۰۵
📦 پروژه: xAiApi

🧠 گام ۱: تحلیل قابلیت‌های مدل Qwen3.5-9B GGUF

مدل Qwen3.5-9B-The-Defiant-Fable-Uncensored-Heretic-NEO-IMATRIX-MAX-MTP-GGUF یک مدل زبانی بزرگ کوانتیزه شده (GGUF) با ویژگی‌های منحصر به فرد است:

👁️ قابلیت Vision / OCR

  • توانایی خواندن متن از تصاویر (PNG, JPG, WEBP)
  • تشخیص جداول و ساختارهای بصری در اسناد اسکن‌شده
  • حذف نیاز به کتابخانه‌های سنتی OCR مانند Tesseract

🚫 Uncensored & Heretic

  • بدون فیلترهای اخلاقی سخت‌گیرانه
  • استخراج دقیق محتوا بدون "مoralizing" یا رد درخواست برای اسناد حساس
  • ایده‌آل برای پردازش اسناد حقوقی، پزشکی یا فنی خام

⚡ IMATRIX & MTP

  • بهینه‌سازی شده برای سرعت استنتاج (Inference) بالاتر
  • پشتیبانی از Context Window گسترده (معمولاً 8K تا 32K توکن)
  • مناسب برای پردازش اسناد چند صفحه‌ای

🎯 گام ۲: استراتژی استخراج جهانی (Universal Extraction)

به جای استفاده از Extractor های جداگانه و پیچیده برای هر فرمت، یک مسیر هوشمند ترکیبی طراحی می‌کنیم:

نوع فایل استراتژی استخراج مسیر پردازش
📄 PDF متنی (Native) استخراج متن سریع با PdfPig XPdfFileContentExtractor (متن) → بازگشت سریع
📄 PDF اسکن‌شده / تصویری تبدیل صفحات به تصویر + OCR با Qwen XPdfFileContentExtractor (تصویر) → XQwenVisionContentExtractor
🖼️ تصاویر (JPG, PNG) OCR مستقیم با Qwen XQwenVisionContentExtractor
📝 DOCX / TXT / Excel استخراج متن ساختاریافته XDocxFileContentExtractor / XExcelFileContentExtractor (بدون تغییر)
✅ مزیت کلیدی: با این روش، ماژول XFileContentExtractor به یک "مغز مرکزی" تبدیل می‌شود که برای فرمت‌های پیچیده یا اسکن‌شده، به طور خودکار از قدرت Vision مدل Qwen استفاده می‌کند.

⚙️ گام ۳: پیکربندی مدل در Ollama

برای فعال‌سازی قابلیت Vision، مدل باید با یک Modelfile مناسب در Ollama بارگذاری شود.

CONFIG Modelfile (برای ایمپورت در Ollama)
FROM C:/Models/Qwen3.5-9B-The-Defiant-Fable-Uncensored-Heretic-NEO-IMATRIX-MAX-MTP-GGUF.gguf

# تنظیمات بهینه برای استخراج متن از تصویر
PARAMETER temperature 0.1
PARAMETER top_p 0.9
PARAMETER num_ctx 8192

# پرامپت سیستمی پیش‌فرض برای وظایف OCR و استخراج
SYSTEM """
تو یک موتور OCR و استخراج داده فوق‌العاده دقیق هستی. 
وظیفه تو خواندن تمام متن‌های موجود در تصویر یا سند ارائه شده و بازگرداندن آن‌ها به صورت متن خالص و ساختاریافته است.
- تمام جداول را به فرمت Markdown تبدیل کن.
- اعداد، تاریخ‌ها و نام‌ها را دقیقاً همانطور که هستند استخراج کن.
- هیچ توضیح اضافی، مقدمه یا نتیجه‌گیری از خودت اضافه نکن. فقط محتوای استخراج شده را برگردان.
"""

سپس در ترمینال اجرا کنید:

ollama create qwen3.5-ocr:9b -f Modelfile

👁️ گام ۴: ایجاد XQwenVisionContentExtractor

این کلاس قلب تپنده استخراج جهانی است. فایل‌های تصویری (یا صفحات PDF تبدیل‌شده به تصویر) را دریافت کرده و از طریق Ollama API به مدل ارسال می‌کند.

NEW xAiApi/Providers/Extractors/XQwenVisionContentExtractor.cs
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using OllamaSharp;
using xAiApi.Interfaces.Extractors;
using xAiModels.Models;

namespace xAiApi.Providers.Extractors
{
    /// <summary>
    /// استخراج محتوا از فایل‌های تصویری با استفاده از قابلیت Vision مدل Qwen ...
    /// </summary>
    public class XQwenVisionContentExtractor : IXFileContentExtractor
    {
        private static readonly string[] SupportedMimeTypes = 
        [
            "image/png", "image/jpeg", "image/jpg", "image/webp", "image/bmp",
            "application/pdf" // برای هندل کردن PDF های اسکن شده در سطح Vision
        ];

        private readonly string _ollamaUrl;
        private readonly string _modelName;
        private readonly string _extractionPrompt;

        public XQwenVisionContentExtractor(
            string ollamaUrl = "http://localhost:11434",
            string modelName = "qwen3.5-ocr:9b",
            string extractionPrompt = "تمام متن موجود در این تصویر را با دقت بالا استخراج کن و به صورت متن خالص برگردان.")
        {
            _ollamaUrl = ollamaUrl;
            _modelName = modelName;
            _extractionPrompt = extractionPrompt;
        }

        public bool CanExtract(string mimeType)
        {
            return SupportedMimeTypes.Contains(mimeType?.ToLowerInvariant() ?? string.Empty);
        }

        public async Task<string> ExtractAsync(
            Stream fileStream,
            string mimeType,
            CancellationToken cancellationToken = default)
        {
            var result = await ExtractRichAsync(fileStream, "file", mimeType, cancellationToken);
            return result.Text;
        }

        public async Task<XFileExtractionResult> ExtractRichAsync(
            Stream fileStream,
            string fileName,
            string mimeType,
            CancellationToken cancellationToken = default)
        {
            var result = new XFileExtractionResult
            {
                FileName = fileName,
                MimeType = mimeType
            };

            try
            {
                // ۱. خواندن استریم به صورت بایت (برای ارسال به مدل Vision)
                using var memoryStream = new MemoryStream();
                await fileStream.CopyToAsync(memoryStream, cancellationToken);
                var imageBytes = memoryStream.ToArray();

                // ۲. تنظیم کلاینت Ollama
                var ollamaClient = new OllamaApiClient(new Uri(_ollamaUrl), _modelName);

                // ۳. ساخت پیام چندوجهی (Text + Image)
                var messages = new[]
                {
                    new ChatMessage(ChatRole.User, new[]
                    {
                        new TextContent(_extractionPrompt),
                        new DataContent(imageBytes, mimeType) // ارسال تصویر به مدل
                    })
                };

                // ۴. فراخوانی مدل برای استخراج متن
                var response = await ollamaClient.GetResponseAsync(messages, cancellationToken: cancellationToken);

                if (!string.IsNullOrWhiteSpace(response.Text))
                {
                    result.Text = response.Text.Trim();
                    result.IsValid = true;
                }
                else
                {
                    result.ErrorMessage = "مدل پاسخی برای استخراج تولید نکرد.";
                }
            }
            catch (Exception ex)
            {
                result.ErrorMessage = $"خطا در استخراج Vision: {ex.Message}";
            }

            return result;
        }
    }
}
💡 نکته فنی: کلاس DataContent از Microsoft.Extensions.AI به طور خودکار بایت‌های تصویر را به فرمت Base64 مناسب برای API مدل‌های Vision تبدیل می‌کند.

📄 گام ۵: به‌روزرسانی XPdfFileContentExtractor

اکنون منطق Fallback را تغییر می‌دهیم. به جای اینکه فقط تصویر را ذخیره کنیم، آن تصویر را به XQwenVisionContentExtractor می‌فرستیم تا متن را استخراج کند.

MODIFY xAiApi/Providers/Extractors/XPdfFileContentExtractor.cs
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UglyToad.PdfPig;
using xAiApi.Interfaces.Extractors;
using xAiModels.Models;

namespace xAiApi.Providers.Extractors
{
    public class XPdfFileContentExtractor : IXFileContentExtractor
    {
        private const int MinTextLengthThreshold = 100;
        private const int MaxPagesForImageFallback = 5; // محدود کردن برای جلوگیری از مصرف بیش از حد GPU

        private readonly IXFileContentExtractor _visionExtractor;

        // تزریق Vision Extractor از طریق Constructor
        public XPdfFileContentExtractor(IXFileContentExtractor visionExtractor)
        {
            _visionExtractor = visionExtractor;
        }

        public bool CanExtract(string mimeType) => mimeType?.ToLowerInvariant() == "application/pdf";

        public async Task<string> ExtractAsync(Stream fileStream, string mimeType, CancellationToken cancellationToken = default)
        {
            var result = await ExtractRichAsync(fileStream, "document.pdf", mimeType, cancellationToken);
            return result.Text;
        }

        public async Task<XFileExtractionResult> ExtractRichAsync(
            Stream fileStream,
            string fileName,
            string mimeType,
            CancellationToken cancellationToken = default)
        {
            var result = new XFileExtractionResult { FileName = fileName, MimeType = mimeType };

            using var memoryStream = new MemoryStream();
            await fileStream.CopyToAsync(memoryStream, cancellationToken);
            
            // مرحله ۱: تلاش برای استخراج متن معمولی
            memoryStream.Position = 0;
            var extractedText = await ExtractTextAsync(memoryStream, cancellationToken);

            if (IsTextSufficient(extractedText))
            {
                result.Text = extractedText;
                result.IsValid = true;
                return result;
            }

            // مرحله ۲: Fallback به Vision OCR
            memoryStream.Position = 0;
            result = await FallbackToVisionOcrAsync(memoryStream, fileName, cancellationToken);
            result.UsedImageFallback = true;

            return result;
        }

        private async Task<string> ExtractTextAsync(Stream pdfStream, CancellationToken cancellationToken)
        {
            return await Task.Run(() =>
            {
                var sb = new StringBuilder();
                using var document = PdfDocument.Open(pdfStream);
                foreach (var page in document.GetPages())
                {
                    sb.AppendLine(page.Text?.Trim());
                }
                return sb.ToString();
            }, cancellationToken);
        }

        private bool IsTextSufficient(string text)
        {
            if (string.IsNullOrWhiteSpace(text)) return false;
            var cleanText = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray());
            return cleanText.Length >= MinTextLengthThreshold;
        }

        private async Task<XFileExtractionResult> FallbackToVisionOcrAsync(
            Stream pdfStream,
            string fileName,
            CancellationToken cancellationToken)
        {
            var result = new XFileExtractionResult { FileName = fileName, MimeType = "application/pdf", UsedImageFallback = true };
            var sb = new StringBuilder();

            await Task.Run(() =>
            {
                using var document = PdfDocument.Open(pdfStream);
                var pageCount = Math.Min(document.NumberOfPages, MaxPagesForImageFallback);

                for (int i = 0; i < pageCount; i++)
                {
                    // تبدیل صفحه به تصویر (با استفاده از PdfPig یا Pdfium)
                    // نکته: برای سادگی، فرض می‌کنیم متدی داریم که صفحه را به Stream تصویر تبدیل می‌کند
                    // در پیاده‌سازی واقعی از PdfiumViewer.RenderPage استفاده کنید (همانند کد قبلی)
                    using var pageImageStream = RenderPageToImageStream(document, i);
                    
                    // ارسال تصویر به Qwen Vision Extractor
                    var ocrResult = _visionExtractor.ExtractRichAsync(
                        pageImageStream, 
                        $"{fileName}_page_{i+1}", 
                        "image/png", 
                        cancellationToken).GetAwaiter().GetResult();

                    if (ocrResult.IsValid)
                    {
                        sb.AppendLine($"--- Page {i + 1} ---");
                        sb.AppendLine(ocrResult.Text);
                    }
                }
            }, cancellationToken);

            result.Text = sb.ToString();
            result.IsValid = !string.IsNullOrWhiteSpace(result.Text);
            return result;
        }

        // Placeholder: باید با منطق PdfiumViewer که قبلاً نوشتید جایگزین شود
        private Stream RenderPageToImageStream(dynamic document, int pageIndex)
        {
            // پیاده‌سازی RenderPage به MemoryStream از PdfiumViewer
            throw new NotImplementedException("از منطق PdfiumViewer.RenderPage استفاده کنید");
        }
    }
}

🔌 گام ۶: ثبت سرویس‌ها در Dependency Injection

اکنون باید زنجیره Extractor ها را در Startup.cs به درستی متصل کنیم.

MODIFY xAiApi/Startup.cs (متد ConfigureServices)
public void ConfigureServices(IServiceCollection services)
{
    // ... (ثبت‌های قبلی)

    // ۱. ثبت Vision Extractor به صورت Singleton (چون State-less است)
    services.AddSingleton<IXFileContentExtractor>(sp => 
        new XQwenVisionContentExtractor(
            ollamaUrl: "http://localhost:11434",
            modelName: "qwen3.5-ocr:9b"
        ));

    // ۲. ثبت PDF Extractor و تزریق Vision Extractor به آن
    services.AddSingleton<IXFileContentExtractor>(sp => 
    {
        var visionExtractor = sp.GetRequiredService<IXFileContentExtractor>(); 
        // نکته: برای جلوگیری از تداخل در Resolve، بهتر است Vision Extractor را با نام/interface خاص ثبت کنید 
        // یا مستقیماً اینstantiate کنید:
        var specificVisionExtractor = new XQwenVisionContentExtractor();
        return new XPdfFileContentExtractor(specificVisionExtractor);
    });

    // ۳. ثبت سایر Extractor ها
    services.AddSingleton<IXFileContentExtractor, XPlainTextFileContentExtractor>();
    services.AddSingleton<IXFileContentExtractor, XDocxFileContentExtractor>();
    services.AddSingleton<IXFileContentExtractor, XExcelFileContentExtractor>();

    // ۴. ثبت Composite Extractor (این کلاس به طور خودکار همه IXFileContentExtractor های ثبت شده را جمع‌آوری می‌کند)
    services.AddSingleton<XFileContentExtractor>();
    
    // اطمینان از اینکه سرویس اصلی از نوع Composite است
    services.AddSingleton<IXFileContentExtractor>(sp => sp.GetRequiredService<XFileContentExtractor>());

    // ... (بقیه کدها)
}

📝 گام ۷: مهندسی پرامپت برای OCR دقیق

کیفیت استخراج مستقیماً به پرامپت ارسال شده به مدل Qwen بستگی دارد. این پرامپت‌ها را در appsettings.json یا کد تعریف کنید:

🎯 پرامپت استخراج عمومی (General OCR)

"تمام متن موجود در این تصویر را با دقت کاراکتر به کاراکتر استخراج کن. ساختار پاراگراف‌ها را حفظ کن. اگر جدولی وجود دارد، آن را به فرمت Markdown تبدیل کن. هیچ توضیح اضافی نده."

📊 پرامپت استخراج داده ساختاریافته (Structured)

"این تصویر یک فاکتور/سند است. فقط موارد زیر را استخراج و به صورت JSON برگردان: {\"issuer\": \"\", \"date\": \"\", \"total_amount\": \"\", \"items\": []}. اگر موردی یافت نشد، null بگذار."

⚠️ گام ۸: ملاحظات عملکردی و Production

چالش راه‌حل پیشنهادی در معماری
محدودیت Context Window در XPdfFileContentExtractor، MaxPagesForImageFallback را روی ۵ تا ۱۰ تنظیم کنید تا مدل Overload نشود.
زمان پاسخ‌دهی (Latency) پردازش Vision زمان‌بر است. در AskAsync از Timeout مناسب (مثلاً ۶۰ ثانیه) در HttpClient استفاده کنید.
مصرف حافظه GPU مدل 9B حدود ۶-۸ گیگابایت VRAM نیاز دارد. اطمینان حاصل کنید سرور Ollama منابع کافی دارد. از پردازش همزمان بیش از ۲ فایل بزرگ خودداری کنید.
کیفیت OCR زبان فارسی مدل‌های Qwen در فارسی خوب عمل می‌کنند، اما برای اسناد بسیار قدیمی یا با کیفیت پایین، ممکن است نیاز به پیش‌پردازش تصویر (افزایش کنتراست) باشد.
⚠️ نکته حیاتی درباره XFileContentExtractor فعلی:
کد فعلی XFileContentExtractor از Reflection برای یافتن Extractor ها استفاده می‌کند. برای اطمینان از عملکرد صحیح، اطمینان حاصل کنید که XQwenVisionContentExtractor و XPdfFileContentExtractor در همان Assembly (پروژه xAiApi) کامپایل شده‌اند تا توسط Reflection شناسایی شوند.