Udostępnij za pośrednictwem


Implementowanie niestandardowego magazynu dla bota

DOTYCZY: ZESTAW SDK w wersji 4

Interakcje bota dzielą się na trzy obszary: wymianę działań z usługą Azure AI Bot Service, ładowanie i zapisywanie bota oraz stanu okna dialogowego z magazynem pamięci oraz integrację z usługami zaplecza.

Diagram interakcji przedstawiający relację między usługą Azure AI Bot Service, botem, magazynem pamięci i innymi usługami.

W tym artykule opisano sposób rozszerzania semantyki między usługą Azure AI Bot Service a stanem pamięci i magazynem bota.

Uwaga

Zestawy SDK języka JavaScript, C# i Python platformy Bot Framework będą nadal obsługiwane, jednak zestaw SDK języka Java jest wycofywany z ostatecznym długoterminowym wsparciem kończącym się w listopadzie 2023 r.

Istniejące boty utworzone za pomocą zestawu JAVA SDK będą nadal działać.

W przypadku tworzenia nowych botów rozważ użycie programu Microsoft Copilot Studio i przeczytaj o wyborze odpowiedniego rozwiązania copilot.

Aby uzyskać więcej informacji, zobacz Przyszłość tworzenia botów.

Wymagania wstępne

Ten artykuł koncentruje się na wersji języka C# przykładu.

Tło

Zestaw SDK platformy Bot Framework zawiera domyślną implementację stanu bota i magazynu pamięci. Ta implementacja odpowiada potrzebom aplikacji, w których fragmenty są używane razem z kilkoma wierszami kodu inicjowania, jak pokazano w wielu przykładach.

Zestaw SDK jest strukturą, a nie aplikacją ze stałym zachowaniem. Innymi słowy implementacja wielu mechanizmów w strukturze jest domyślną implementacją, a nie jedyną możliwą implementacją. Platforma nie określa relacji między wymianą działań z usługą Azure AI Bot Service a ładowaniem i zapisywaniem dowolnego stanu bota.

W tym artykule opisano jeden ze sposobów modyfikowania semantyki domyślnego stanu i implementacji magazynu, gdy nie działa on w przypadku aplikacji. Przykład skalowania w poziomie zawiera alternatywną implementację stanu i magazynu, która ma różne semantyki niż te domyślne. To alternatywne rozwiązanie znajduje się równie dobrze w strukturze. W zależności od scenariusza to alternatywne rozwiązanie może być bardziej odpowiednie dla opracowywanej aplikacji.

Zachowanie domyślnego adaptera i dostawcy magazynu

W przypadku domyślnej implementacji podczas odbierania działania bot ładuje stan odpowiadający konwersacji. Następnie uruchamia logikę okna dialogowego z tym stanem i działaniem przychodzącym. W trakcie uruchamiania okna dialogowego co najmniej jedno działanie wychodzące jest tworzone i natychmiast wysyłane. Po zakończeniu przetwarzania okna dialogowego bot zapisuje zaktualizowany stan, zastępując stary stan.

Diagram sekwencji przedstawiający domyślne zachowanie bota i jego magazynu pamięci.

Jednak kilka rzeczy może pójść nie tak z tym zachowaniem.

  • Jeśli operacja zapisywania nie powiedzie się z jakiegoś powodu, stan niejawnie wyślizgnął się z synchronizacji z tym, co użytkownik widzi w kanale. Użytkownik widział odpowiedzi bota i uważa, że stan został przeniesiony do przodu, ale nie. Ten błąd może być gorszy niż w przypadku pomyślnej aktualizacji stanu, ale użytkownik nie otrzymał komunikatów odpowiedzi.

    Takie błędy stanu mogą mieć wpływ na projekt konwersacji. Na przykład okno dialogowe może wymagać dodatkowej, w przeciwnym razie nadmiarowej wymiany potwierdzenia z użytkownikiem.

  • Jeśli implementacja jest wdrażana skalowana w poziomie w wielu węzłach, stan może zostać przypadkowo zastąpiony. Ten błąd może być mylący, ponieważ okno dialogowe prawdopodobnie wyśle działania do kanału z komunikatami potwierdzającymi.

    Rozważmy bota zamówienia pizzy, w którym bot prosi użytkownika o wybór toppingu, a użytkownik wysyła dwa szybkie wiadomości: jeden, aby dodać grzyby i jeden do dodania sera. W scenariuszu skalowanym w poziomie wiele wystąpień bota może być aktywnych, a dwa komunikaty użytkownika mogą być obsługiwane przez dwa oddzielne wystąpienia na oddzielnych maszynach. Taki konflikt jest określany jako warunek wyścigu, w którym jedna maszyna może zastąpić stan napisany przez innego. Jednak ze względu na to, że odpowiedzi zostały już wysłane, użytkownik otrzymał potwierdzenie, że zarówno grzyby, jak i ser zostały dodane do zamówienia. Niestety, gdy przybywa pizza, zawiera tylko grzyby lub ser, ale nie oba.

Optymistyczne blokowanie

Przykład skalowania w poziomie wprowadza pewne blokady wokół stanu. Przykład implementuje optymistyczne blokowanie, dzięki czemu każde wystąpienie działa tak, jakby było to jedyne uruchomione, a następnie sprawdza, czy występują jakiekolwiek naruszenia współbieżności. Ta blokada może wydawać się skomplikowana, ale istnieją znane rozwiązania i można używać technologii magazynowania w chmurze i odpowiednich punktów rozszerzenia w strukturze Bot Framework.

W przykładzie użyto standardowego mechanizmu HTTP opartego na nagłówku tagu jednostki (ETag). Zrozumienie tego mechanizmu ma kluczowe znaczenie dla zrozumienia poniższego kodu. Na poniższym diagramie przedstawiono sekwencję.

Diagram sekwencji przedstawiający stan wyścigu z drugą aktualizacją, która kończy się niepowodzeniem.

Diagram zawiera dwóch klientów, którzy wykonują aktualizację do niektórych zasobów.

  1. Gdy klient wystawia żądanie GET, a zasób jest zwracany z serwera, serwer zawiera nagłówek ETag.

    Nagłówek ETag jest nieprzezroczystą wartością reprezentującą stan zasobu. Jeśli zasób zostanie zmieniony, serwer zaktualizuje jego element ETag dla zasobu.

  2. Gdy klient chce utrwał zmianę stanu, wysyła żądanie POST do serwera z wartością ETag w If-Match nagłówku warunku wstępnego.

  3. Jeśli wartość elementu ETag żądania nie jest zgodna z serwerem, sprawdzanie warunków wstępnych kończy się niepowodzeniem z odpowiedzią 412 (Niepowodzenie warunku wstępnego).

    Ten błąd wskazuje, że bieżąca wartość na serwerze nie jest już zgodna z oryginalną wartością, na którą działał klient.

  4. Jeśli klient otrzyma odpowiedź z informacją o niepomyślnie nieudanej odpowiedzi, klient zazwyczaj pobiera nową wartość zasobu, stosuje wymaganą aktualizację i próbuje opublikować aktualizację zasobu ponownie.

    To drugie żądanie POST powiedzie się, jeśli żaden inny klient nie zaktualizował zasobu. W przeciwnym razie klient może spróbować ponownie.

Ten proces jest nazywany optymistycznym , ponieważ klient, gdy ma zasób, przechodzi do jego przetwarzania — sam zasób nie jest zablokowany, ponieważ inni klienci mogą uzyskać do niego dostęp bez żadnych ograniczeń. Wszelkie rywalizacje między klientami o stan zasobu nie zostanie określony, dopóki przetwarzanie nie zostanie wykonane. W systemie rozproszonym ta strategia jest często bardziej optymalna niż odwrotne pesymistyczne podejście.

Optymistyczny mechanizm blokowania zgodnie z opisem zakłada, że logika programu może zostać bezpiecznie ponowiona. Idealna sytuacja polega na tym, że te żądania obsługi są idempotentne. W nauce komputerowej operacja idempotentna jest operacją, która nie ma dodatkowego wpływu, jeśli jest wywoływana więcej niż raz z tymi samymi parametrami wejściowymi. Czyste usługi REST HTTP implementujące żądania GET, PUT i DELETE są często idempotentne. Jeśli żądanie obsługi nie przyniesie dodatkowych efektów, żądania mogą być bezpiecznie ponownie wykonywane w ramach strategii ponawiania prób.

Przykład skalowania w poziomie i pozostała część tego artykułu zakładają, że usługi zaplecza używane przez bota to wszystkie idempotentne usługi REST HTTP.

Buforowanie działań wychodzących

Wysyłanie działania nie jest operacją idempotentną. Działanie jest często komunikatem, który przekazuje informacje użytkownikowi i powtarzanie tego samego komunikatu dwa lub więcej razy może być mylące lub mylące.

Optymistyczne blokowanie oznacza, że logika bota może być ponownie uruchamiana wiele razy. Aby uniknąć wielokrotnego wysyłania danego działania, poczekaj na pomyślne wykonanie operacji aktualizacji stanu przed wysłaniem działań do użytkownika. Logika bota powinna wyglądać podobnie do poniższego diagramu.

Diagram sekwencji z komunikatami wysyłanymi po zapisaniu stanu okna dialogowego.

Po utworzeniu pętli ponawiania prób w wykonaniu okna dialogowego występuje następujące zachowanie w przypadku niepowodzenia warunków wstępnych operacji zapisywania.

Diagram sekwencji z komunikatami wysyłanymi po pomyślnym ponowieniu próby.

Dzięki temu mechanizmowi bot pizzy z wcześniejszego przykładu nigdy nie powinien wysyłać błędnego pozytywnego potwierdzenia dodania pizzy do zamówienia. Nawet w przypadku bota wdrożonego na wielu maszynach optymistyczny schemat blokowania skutecznie serializuje aktualizacje stanu. W botze pizzy potwierdzenie dodania elementu może teraz nawet odzwierciedlać pełny stan dokładnie. Jeśli na przykład użytkownik szybko wpisze "ser", a następnie "grzyb", a te komunikaty są obsługiwane przez dwa różne wystąpienia bota, ostatnie wystąpienie do ukończenia może zawierać "pizzę z serem i grzybem" w ramach odpowiedzi.

To nowe rozwiązanie magazynu niestandardowego wykonuje trzy czynności, których nie wykonuje domyślna implementacja w zestawie SDK:

  1. Używa elementów ETag do wykrywania rywalizacji.
  2. Ponawia próbę przetwarzania po wykryciu błędu ETag.
  3. Oczekuje na wysłanie działań wychodzących do momentu pomyślnego zapisania stanu.

W pozostałej części tego artykułu opisano implementację tych trzech części.

Implementowanie obsługi elementu ETag

Najpierw zdefiniuj interfejs dla naszego nowego sklepu, który obejmuje obsługę elementu ETag. Interfejs ułatwia korzystanie z mechanizmów wstrzykiwania zależności w ASP.NET. Począwszy od interfejsu, można zaimplementować oddzielne wersje testów jednostkowych i środowiska produkcyjnego. Na przykład wersja testu jednostkowego może działać w pamięci i nie wymaga połączenia sieciowego.

Interfejs składa się z metod ładowania i zapisywania . Obie metody będą używać parametru klucza do identyfikowania stanu do załadowania z lub zapisania w magazynie.

  • Obciążenie zwróci wartość stanu i skojarzonego elementu ETag.
  • Zapisanie będzie zawierać parametry dla wartości stanu i skojarzonego elementu ETag oraz zwraca wartość logiczną wskazującą, czy operacja zakończyła się pomyślnie. Wartość zwracana nie będzie służyć jako ogólny wskaźnik błędu, ale zamiast tego jako konkretny wskaźnik niepowodzenia warunków wstępnych. Sprawdzenie kodu zwrotnego będzie częścią logiki pętli ponawiania prób.

Aby implementacja magazynu była powszechnie stosowana, należy unikać umieszczania na nim wymagań dotyczących serializacji. Jednak wiele nowoczesnych usług magazynu obsługuje kod JSON jako typ zawartości. W języku C#można użyć JObject typu do reprezentowania obiektu JSON. W języku JavaScript lub TypeScript kod JSON jest zwykłym obiektem natywnym.

Oto definicja interfejsu niestandardowego.

IStore.cs

public interface IStore
{
    Task<(JObject content, string etag)> LoadAsync(string key);

    Task<bool> SaveAsync(string key, JObject content, string etag);
}

Oto implementacja usługi Azure Blob Storage.

BlobStore.cs

public class BlobStore : IStore
{
    private readonly CloudBlobContainer _container;

    public BlobStore(string accountName, string accountKey, string containerName)
    {
        if (string.IsNullOrWhiteSpace(accountName))
        {
            throw new ArgumentException(nameof(accountName));
        }

        if (string.IsNullOrWhiteSpace(accountKey))
        {
            throw new ArgumentException(nameof(accountKey));
        }

        if (string.IsNullOrWhiteSpace(containerName))
        {
            throw new ArgumentException(nameof(containerName));
        }

        var storageCredentials = new StorageCredentials(accountName, accountKey);
        var cloudStorageAccount = new CloudStorageAccount(storageCredentials, useHttps: true);
        var client = cloudStorageAccount.CreateCloudBlobClient();
        _container = client.GetContainerReference(containerName);
    }

    public async Task<(JObject content, string etag)> LoadAsync(string key)
    {
        if (string.IsNullOrWhiteSpace(key))
        {
            throw new ArgumentException(nameof(key));
        }

        var blob = _container.GetBlockBlobReference(key);
        try
        {
            var content = await blob.DownloadTextAsync();
            var obj = JObject.Parse(content);
            var etag = blob.Properties.ETag;
            return (obj, etag);
        }
        catch (StorageException e)
            when (e.RequestInformation.HttpStatusCode == (int)HttpStatusCode.NotFound)
        {
            return (new JObject(), null);
        }
    }

    public async Task<bool> SaveAsync(string key, JObject obj, string etag)
    {
        if (string.IsNullOrWhiteSpace(key))
        {
            throw new ArgumentException(nameof(key));
        }

        if (obj == null)
        {
            throw new ArgumentNullException(nameof(obj));
        }

        var blob = _container.GetBlockBlobReference(key);
        blob.Properties.ContentType = "application/json";
        var content = obj.ToString();
        if (etag != null)
        {
            try
            {
                await blob.UploadTextAsync(content, Encoding.UTF8, new AccessCondition { IfMatchETag = etag }, new BlobRequestOptions(), new OperationContext());
            }
            catch (StorageException e)
                when (e.RequestInformation.HttpStatusCode == (int)HttpStatusCode.PreconditionFailed)
            {
                return false;
            }
        }
        else
        {
            await blob.UploadTextAsync(content);
        }

        return true;
    }
}

Usługa Azure Blob Storage wykonuje wiele pracy. Każda metoda sprawdza, czy określony wyjątek spełnia oczekiwania kodu wywołującego.

  • MetodaLoadAsync, w odpowiedzi na wyjątek magazynu z nieznanym kodem stanu, zwraca wartość null.
  • Metoda SaveAsync , w odpowiedzi na wyjątek magazynu z warunkiem wstępnym niepowodzenia kodu, zwraca wartość false.

Implementowanie pętli ponawiania prób

Projekt pętli ponawiania implementuje zachowanie pokazane na diagramach sekwencji.

  1. Po otrzymaniu działania utwórz klucz dla stanu konwersacji.

    Relacja między działaniem a stanem konwersacji jest taka sama dla magazynu niestandardowego co w przypadku domyślnej implementacji. W związku z tym można utworzyć klucz w taki sam sposób, jak w przypadku implementacji stanu domyślnego.

  2. Spróbuj załadować stan konwersacji.

  3. Uruchom okna dialogowe bota i przechwyć działania wychodzące do wysłania.

  4. Spróbuj zapisać stan konwersacji.

    • Po pomyślnych działaniach wyślij działania wychodzące i zakończ.

    • Po niepowodzeniu powtórz ten proces z kroku, aby załadować stan konwersacji.

      Nowe obciążenie stanu konwersacji pobiera nowy i bieżący stan ETag i konwersacji. Okno dialogowe zostanie uruchomione ponownie, a krok zapisywania stanu ma szansę na powodzenie.

Oto implementacja procedury obsługi działań komunikatów.

ScaleoutBot.cs

protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
    // Create the storage key for this conversation.
    var key = $"{turnContext.Activity.ChannelId}/conversations/{turnContext.Activity.Conversation?.Id}";

    // The execution sits in a loop because there might be a retry if the save operation fails.
    while (true)
    {
        // Load any existing state associated with this key
        var (oldState, etag) = await _store.LoadAsync(key);

        // Run the dialog system with the old state and inbound activity, the result is a new state and outbound activities.
        var (activities, newState) = await DialogHost.RunAsync(_dialog, turnContext.Activity, oldState, cancellationToken);

        // Save the updated state associated with this key.
        var success = await _store.SaveAsync(key, newState, etag);

        // Following a successful save, send any outbound Activities, otherwise retry everything.
        if (success)
        {
            if (activities.Any())
            {
                // This is an actual send on the TurnContext we were given and so will actual do a send this time.
                await turnContext.SendActivitiesAsync(activities, cancellationToken);
            }

            break;
        }
    }
}

Uwaga

Przykład implementuje wykonywanie okna dialogowego jako wywołanie funkcji. Bardziej wyrafinowanym podejściem może być zdefiniowanie interfejsu i użycie wstrzykiwania zależności. W tym przykładzie funkcja statyczna podkreśla jednak funkcjonalny charakter tego optymistycznego podejścia do blokowania. Ogólnie rzecz biorąc, podczas implementowania kluczowych części kodu w sposób funkcjonalny zwiększa się prawdopodobieństwo pomyślnego działania w sieciach.

Implementowanie buforu działań wychodzących

Następnym wymaganiem jest buforowanie działań wychodzących do momentu pomyślnego wykonania operacji zapisywania, która wymaga implementacji adaptera niestandardowego. Metoda niestandardowa SendActivitiesAsync nie powinna wysyłać działań do użycia, ale dodawać działania do listy. Kod okna dialogowego nie będzie wymagał modyfikacji.

  • W tym konkretnym scenariuszu działania aktualizacji i operacji usuwania nie są obsługiwane, a skojarzone metody nie zgłaszają wyjątków.
  • Wartość zwracana z operacji wysyłania działań jest używana przez niektóre kanały, aby umożliwić botowi modyfikowanie lub usuwanie wcześniej wysłanego komunikatu, na przykład w celu wyłączenia przycisków na kartach wyświetlanych w kanale. Te wymiany komunikatów mogą być skomplikowane, szczególnie wtedy, gdy jest wymagany stan i znajdują się poza zakresem tego artykułu.
  • Okno dialogowe tworzy i używa tej karty niestandardowej, dzięki czemu może buforować działania.
  • Procedura obsługi kolei bota będzie używać bardziej standardowego do AdapterWithErrorHandler wysyłania działań do użytkownika.

Oto implementacja karty niestandardowej.

DialogHostAdapter.cs

public class DialogHostAdapter : BotAdapter
{
    private List<Activity> _response = new List<Activity>();

    public IEnumerable<Activity> Activities => _response;

    public override Task<ResourceResponse[]> SendActivitiesAsync(ITurnContext turnContext, Activity[] activities, CancellationToken cancellationToken)
    {
        foreach (var activity in activities)
        {
            _response.Add(activity);
        }

        return Task.FromResult(new ResourceResponse[0]);
    }

    #region Not Implemented
    public override Task DeleteActivityAsync(ITurnContext turnContext, ConversationReference reference, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public override Task<ResourceResponse> UpdateActivityAsync(ITurnContext turnContext, Activity activity, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
    #endregion
}

Używanie magazynu niestandardowego w botze

Ostatnim krokiem jest użycie tych niestandardowych klas i metod z istniejącymi klasami i metodami struktury.

  • Główna pętla ponawiania prób staje się częścią metody bota ActivityHandler.OnMessageActivityAsync i obejmuje magazyn niestandardowy poprzez wstrzyknięcie zależności.
  • Kod hostingu okna dialogowego jest dodawany do DialogHost klasy, która uwidacznia metodę statyczną RunAsync . Host okna dialogowego:
    • Pobiera działanie przychodzące i stary stan, a następnie zwraca wynikowe działania i nowy stan.
    • Tworzy kartę niestandardową i w przeciwnym razie uruchamia okno dialogowe w taki sam sposób, jak w przypadku zestawu SDK.
    • Tworzy niestandardową metodę dostępu właściwości stanu, podkładkę przekazującą stan okna dialogowego do systemu dialogowego. Akcesorium używa semantyki odwołań do przekazywania uchwytu dostępu do systemu okien dialogowych.

Napiwek

Serializacja JSON jest dodawana w tekście do kodu hostingu, aby zachować go poza podłączaną warstwą magazynu, dzięki czemu różne implementacje mogą serializować inaczej.

Oto implementacja hosta okna dialogowego.

DialogHost.cs

public static class DialogHost
{
    // The serializer to use. Moving the serialization to this layer will make the storage layer more pluggable.
    private static readonly JsonSerializer StateJsonSerializer = new JsonSerializer() { TypeNameHandling = TypeNameHandling.All };

    /// <summary>
    /// A function to run a dialog while buffering the outbound Activities.
    /// </summary>
    /// <param name="dialog">THe dialog to run.</param>
    /// <param name="activity">The inbound Activity to run it with.</param>
    /// <param name="oldState">Th eexisting or old state.</param>
    /// <returns>An array of Activities 'sent' from the dialog as it executed. And the updated or new state.</returns>
    public static async Task<(Activity[], JObject)> RunAsync(Dialog dialog, IMessageActivity activity, JObject oldState, CancellationToken cancellationToken)
    {
        // A custom adapter and corresponding TurnContext that buffers any messages sent.
        var adapter = new DialogHostAdapter();
        var turnContext = new TurnContext(adapter, (Activity)activity);

        // Run the dialog using this TurnContext with the existing state.
        var newState = await RunTurnAsync(dialog, turnContext, oldState, cancellationToken);

        // The result is a set of activities to send and a replacement state.
        return (adapter.Activities.ToArray(), newState);
    }

    /// <summary>
    /// Execute the turn of the bot. The functionality here closely resembles that which is found in the
    /// IBot.OnTurnAsync method in an implementation that is using the regular BotFrameworkAdapter.
    /// Also here in this example the focus is explicitly on Dialogs but the pattern could be adapted
    /// to other conversation modeling abstractions.
    /// </summary>
    /// <param name="dialog">The dialog to be run.</param>
    /// <param name="turnContext">The ITurnContext instance to use. Note this is not the one passed into the IBot OnTurnAsync.</param>
    /// <param name="state">The existing or old state of the dialog.</param>
    /// <returns>The updated or new state of the dialog.</returns>
    private static async Task<JObject> RunTurnAsync(Dialog dialog, ITurnContext turnContext, JObject state, CancellationToken cancellationToken)
    {
        // If we have some state, deserialize it. (This mimics the shape produced by BotState.cs.)
        var dialogStateProperty = state?[nameof(DialogState)];
        var dialogState = dialogStateProperty?.ToObject<DialogState>(StateJsonSerializer);

        // A custom accessor is used to pass a handle on the state to the dialog system.
        var accessor = new RefAccessor<DialogState>(dialogState);

        // Run the dialog.
        await dialog.RunAsync(turnContext, accessor, cancellationToken);

        // Serialize the result (available as Value on the accessor), and put its value back into a new JObject.
        return new JObject { { nameof(DialogState), JObject.FromObject(accessor.Value, StateJsonSerializer) } };
    }
}

I wreszcie, oto implementacja niestandardowej właściwości stanu metody dostępu.

RefAccessor.cs

public class RefAccessor<T> : IStatePropertyAccessor<T>
    where T : class
{
    public RefAccessor(T value)
    {
        Value = value;
    }

    public T Value { get; private set; }

    public string Name => nameof(T);

    public Task<T> GetAsync(ITurnContext turnContext, Func<T> defaultValueFactory = null, CancellationToken cancellationToken = default(CancellationToken))
    {
        if (Value == null)
        {
            if (defaultValueFactory == null)
            {
                throw new KeyNotFoundException();
            }

            Value = defaultValueFactory();
        }

        return Task.FromResult(Value);
    }

    #region Not Implemented
    public Task DeleteAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
    {
        throw new NotImplementedException();
    }

    public Task SetAsync(ITurnContext turnContext, T value, CancellationToken cancellationToken = default(CancellationToken))
    {
        throw new NotImplementedException();
    }
    #endregion
}

Dodatkowe informacje

Przykład skalowania w poziomie jest dostępny w repozytorium przykładów platformy Bot Framework w usłudze GitHub w języku C#, Python i Java.