🔗 رفع خطای وابستگی چرخشی (Circular Dependency)

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

🔍 گام ۱: تحلیل ریشه خطا

پیغام خطای DI به وضوح یک وابستگی چرخشی (Circular Dependency) را نشان می‌دهد:

IXFileContentExtractor ➔ XVisionFileContentExtractor ➔ IXDefaultAIOCRService ➔ XDefaultAIOCRService ➔ XAIServiceBase ➔ IXFileContentExtractor

چرا این اتفاق افتاد؟

  • کلاس XVisionFileContentExtractor برای انجام OCR به IXDefaultAIOCRService نیاز دارد.
  • کلاس XDefaultAIOCRService از XAIServiceBase ارث‌بری کرده است.
  • سازنده (Constructor) کلاس XAIServiceBase به IXFileContentExtractor نیاز دارد.

این زنجیره باعث می‌شود کانتینر DI در یک حلقه بی‌نهایت گیر کند و نتواند هیچ‌یک از این سرویس‌ها را مقداردهی اولیه نماید.

💡 گام ۲: استراتژی رفع خطا

برای شکستن این چرخه، باید وابستگی XDefaultAIOCRService به XAIServiceBase را حذف کنیم.

سرویس OCR فقط نیاز به برقراری ارتباط با مدل زبانی (LLM) برای استخراج متن از تصویر دارد و به هیچ‌وجه به IXFileContentExtractor، IXFileProvider یا IXAiDataProvider نیاز ندارد. بنابراین، با حذف ارث‌بری از XAIServiceBase و پیاده‌سازی مستقیم منطق ساخت IChatClient درون همین کلاس، چرخه وابستگی کاملاً شکسته می‌شود.

🛠️ گام ۳: اصلاح Interface سرویس OCR

ابتدا باید ارث‌بری غیرضروری IXAiServiceBase را از اینترفیس حذف کنیم، زیرا این سرویس فقط وظیفه OCR را بر عهده دارد و نیازی به متدهای مدیریت پروژه، مکالمه و پیام ندارد.

MODIFY xAiApi/Interfaces/IXDefaultAIOCRService.cs
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using System.Collections.Generic;

namespace xAiApi.Interfaces
{
    /// <summary>
    /// سرویس اختصاصی برای انجام OCR بر روی محتوای تصویری ...
    /// </summary>
    public interface IXDefaultAIOCRService
    {
        /// <summary>
        /// درخواست انجام OCR بر روی محتوای داده شده ...
        /// </summary>
        Task<string> RequestOCRAsync(
            IList<ChatMessage> messages,
            CancellationToken cancellationToken = default
        );

        /// <summary>
        /// درخواست انجام OCR بر روی محتوای داده شده به صورت Stream ...
        /// </summary>
        IAsyncEnumerable<string> RequestOCRAsEnumerable(
            IList<ChatMessage> messages,
            CancellationToken cancellationToken = default
        );
    }
}

⚙️ گام ۴: بازنویسی مستقل کلاس XDefaultAIOCRService

اکنون کلاس پیاده‌سازی را بازنویسی می‌کنیم تا دیگر از XAIServiceBase ارث‌بری نکند. منطق ساخت IChatClient (که قبلاً در کلاس پایه بود) به صورت مستقیم و تمیز درون این کلاس قرار می‌گیرد.

MODIFY xAiApi/Providers/XDefaultAIOCRService.cs
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using OpenAI;
using OllamaSharp;
using System.ClientModel;
using System.ClientModel.Primitives;
using xAiApi.Configurations;
using xAiApi.Constants;
using xAiApi.Extensions;
using xAiModels.Constants;
using xAiModels.Extensions;
using xCommons.Extensions;
using xExceptions.Constants;

namespace xAiApi.Providers
{
    /// <summary>
    /// پیاده‌سازی مستقل سرویس OCR بدون وابستگی به XAIServiceBase ...
    /// </summary>
    public class XDefaultAIOCRService : IXDefaultAIOCRService
    {
        private readonly string prompt;
        private readonly XAiModelDescriptor descriptor;
        private readonly ILogger<XDefaultAIOCRService> logger;

        public XDefaultAIOCRService(
            ILogger<XDefaultAIOCRService> logger,
            XAiApiConfiguration configuration,
            string model = null,
            string prompt = null
        )
        {
            this.logger = logger;

            // ۱. دریافت پیکربندی مدل
            if (model.IsNullOrEmpty())
            {
                model = XAiApiConstants.XAiDefaultVisionModelName;
            }

            descriptor = configuration.GetModel(model);
            if (descriptor.IsNullOrDefault())
            {
                XException.InvalidData.Throw("Invalid OCR Model Configuration");
            }

            // ۲. دریافت پرامپت استخراج
            if (prompt.IsNullOrEmpty())
            {
                prompt = configuration.GetPrompt(
                    name: XAiApiConstants.XAiApiContentExtractionPromptName,
                    @params: null
                );
            }

            this.prompt = prompt;
            if (this.prompt.IsNullOrEmpty())
            {
                XException.InvalidArgs.Throw("OCR Prompt cannot be empty");
            }
        }

        public virtual async Task<string> RequestOCRAsync(
            IList<ChatMessage> messages,
            CancellationToken cancellationToken = default
        )
        {
            if (!messages.HasChild())
            {
                XException.InvalidArgs.Throw();
            }

            using var client = GetClient();

            var pMessage = new ChatMessage(ChatRole.System, prompt);
            messages = [pMessage, .. messages];

            var response = await client.GetResponseAsync(
                messages: messages,
                cancellationToken: cancellationToken
            );

            if (!response.IsValid())
            {
                XException.ActionFailed.Throw();
            }

            return response.Text;
        }

        public virtual async IAsyncEnumerable<string> RequestOCRAsEnumerable(
            IList<ChatMessage> messages,
            [EnumeratorCancellation] CancellationToken cancellationToken = default
        )
        {
            if (!messages.HasChild())
            {
                XException.InvalidArgs.Throw();
            }

            using var client = GetClient();

            var pMessage = new ChatMessage(ChatRole.System, prompt);
            messages = [pMessage, .. messages];

            var enumerable = client.GetStreamingResponseAsync(
                options: null,
                messages: messages
            );

            await foreach (var res in enumerable)
            {
                if (cancellationToken.IsCancellationRequested)
                {
                    yield break;
                }
                yield return res.Text;
            }
        }

        /// <summary>
        /// ساخت کلاینت ارتباط با LLM (جایگزین متد GetClient در XAIServiceBase) ...
        /// </summary>
        private IChatClient GetClient()
        {
            var model = descriptor.LLM;
            var apiKey = descriptor.ApiKey;
            var url = new Uri(descriptor.Url);
            var httpClient = new HttpClient { BaseAddress = url, Timeout = Timeout.InfiniteTimeSpan };

            IChatClient result = null;
            switch (descriptor.Provider)
            {
                case XAiModelProviderType.Ollama:
                    var ollamaClient = new OllamaApiClient(httpClient, model);
                    result = new ChatClientBuilder(ollamaClient).UseFunctionInvocation().Build();
                    break;
                case XAiModelProviderType.OpenAI:
                    var openAiClient = new OpenAIClient(
                        new ApiKeyCredential(apiKey.IsNullOrEmpty() ? XAiApiConstants.XOpenAINoKey : apiKey),
                        new OpenAIClientOptions
                        {
                            Endpoint = url,
                            Transport = new HttpClientPipelineTransport(httpClient)
                        }
                    );
                    result = new ChatClientBuilder(openAiClient.GetChatClient(model).AsIChatClient()).UseFunctionInvocation().Build();
                    break;
                default:
                    XException.InvalidData.Throw($"Unsupported provider: {descriptor.Provider}");
                    break;
            }

            if (result == null)
            {
                XException.InvalidData.Throw("Failed to create Chat Client");
            }

            return result;
        }
    }
}

🔌 گام ۵: بررسی ثبت در Dependency Injection

با توجه به تغییرات فوق، ثبت سرویس در فایل Startup.cs بدون هیچ تغییری به درستی کار خواهد کرد، زیرا امضای Constructor اکنون ساده‌تر شده و فقط به ILogger و XAiApiConfiguration وابسته است که هر دو از قبل در DI ثبت شده‌اند.

✅ تأییدیه:
خط services.AddScoped<IXDefaultAIOCRService, XDefaultAIOCRService>(); در Startup.cs کاملاً معتبر است و دیگر باعث ایجاد چرخه وابستگی نمی‌شود.

خلاصه دستاوردهای این اصلاح:

مزیت توضیح
🚫 حذف Circular Dependency چرخه معیوب بین Extractor و سرویس OCR کاملاً شکسته شد.
⚡ افزایش عملکرد (Performance) سرویس OCR دیگر بار اضافی مقداردهی اولیه DataProvider و FileProvider را تحمل نمی‌کند.
🎯 اصل تک‌وظیفه‌ای (SRP) کلاس XDefaultAIOCRService اکنون فقط و فقط مسئول ارتباط با مدل برای OCR است.
🧪 قابلیت تست‌پذیری (Testability) تزریق وابستگی‌های کمتر، نوشتن Unit Test برای این سرویس را بسیار ساده‌تر می‌کند.