Создание простых инструментов Opal для поиска продуктов и создания контента – PÄR WISSMARK – АРХИТЕКТОР И РАЗРАБОТЧИК OPTIMIZELY SOLUTION

Инструменты Optimizely Opal позволяют агентам ИИ легко вызывать ваши API — в этом посте мы создадим небольшой хост ASP.NET, который предоставляет два из них: один для поиска продуктов и один для создания контента CMS.

Мы рассмотрим конкретный пример: небольшой API-интерфейс ASP.NET, который предоставляет два инструмента через Opal — инструмент поиска продукта, который запрашивает данные о продукте, и инструмент создания страниц CMS, который создает контент во внешней CMS. Попутно мы рассмотрим, как приложение регистрирует инструменты Opal и отображает их в /opal/*провода набраны HttpClient экземпляров для внешних систем и защищает вызовы инструментов с помощью простого средства проверки токена носителя. Цель состоит не в создании массивного, универсального бэкэнда, а в создании чистого и понятного API-интерфейса утилиты, который размещает два инструмента в одном процессе.

Начальная настройка

В Program.cs мы загружаем хост инструмента Opal: регистрируем среду выполнения Opal, подключаем типизированных HTTP-клиентов для системы продукта и CMS и настраиваем небольшую часть промежуточного программного обеспечения для защиты вызовов инструмента с помощью токена-носителя. Эта настройка превращает простое приложение в служебный API с поддержкой Opal. Он предоставляет инструменты под /opal/tools* для аутентифицированных абонентов, сохраняя при этом отдельный /opal/discovery конечная точка открыта, чтобы агенты могли узнать, какие инструменты доступны, не получая прямого доступа к защищенным операциям.

Интеграция инструментов построена с использованием пакета NuGet Optimizely.Opal.Tools 0.4.0(https://www.nuget.org/packages/Optimizely.Opal.Tools/), а решение основано на проекте .NET 10.

Структура проекта выглядит следующим образом.

Программа.cs

using OpalToolsService.Auth;
using OpalToolsService.Services.Cms;
using OpalToolsService.Services.ProductData;
using OpalToolsService.Tools;
using Optimizely.Opal.Tools;

var builder = WebApplication.CreateBuilder(args);

// Authentication
builder.Services.AddSingleton();

// Product data HTTP client
builder.Services.AddHttpClient((service, client) =>
{
    var configuration = service.GetRequiredService();
    ConfigureBaseAddress(client, configuration, "ExternalApi:BaseUrl", "external system");
});

// CMS HTTP client
builder.Services.AddHttpClient((service, client) =>
{
    var configuration = service.GetRequiredService();
    ConfigureBaseAddress(client, configuration, "CmsApi:BaseUrl", "CMS");
});

// Opal tools
builder.Services.AddOpalToolService();
builder.Services.AddOpalTool();
builder.Services.AddOpalTool();

var app = builder.Build();

// Bearer-token protection for tools
var tokenValidator = app.Services.GetRequiredService();

app.Use(async (context, next) =>
{
    var path = context.Request.Path.Value ?? string.Empty;
    var isToolCall = path.StartsWith("/opal/tools", StringComparison.OrdinalIgnoreCase);
    var isDiscovery = path.Equals("/opal/discovery", StringComparison.OrdinalIgnoreCase);

    if (isToolCall && !isDiscovery)
    {
        if (!context.Request.Headers.TryGetValue("Authorization", out var authHeader) || !tokenValidator.IsValid(authHeader.ToString()))
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await context.Response.WriteAsync("Unauthorized");
            return;
        }
    }

    await next(context);
});

// Map Opal endpoints at /opal/*
app.MapOpalTools("/opal/");

app.Run();

static void ConfigureBaseAddress(HttpClient client, IConfiguration configuration, string configurationKey, string clientLabel)
{
    var baseUrl = configuration[configurationKey];
    if (string.IsNullOrWhiteSpace(baseUrl))
    {
        throw new InvalidOperationException($"Configuration '{configurationKey}' is required for the {clientLabel} client.");
    }

    if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri))
    {
        throw new InvalidOperationException($"Configuration '{configurationKey}' must be an absolute URI for the {clientLabel} client.");
    }

    client.BaseAddress = uri;
}

Этот класс централизует проверку токенов-носителей для вызовов инструментов Opal. Он считывает ожидаемый токен из конфигурации. Opal:BearerToken когда приложение запускается и предоставляет IsValid метод, который проверяет входящий `Authorization` заголовок для правильно отформатированного Bearer значение, соответствующее настроенному токену. Если заголовок отсутствует, имеет неверный формат или содержит неправильный токен, метод просто возвращает false.

BearerTokenValidator.cs

namespace OpalToolsService.Auth;

public class BearerTokenValidator(IConfiguration configuration)
{
    private readonly string _expectedToken = configuration["Opal:BearerToken"] ?? throw new InvalidOperationException("Opal:BearerToken must be configured");

    public bool IsValid(string authorizationHeader)
    {
        if (string.IsNullOrWhiteSpace(authorizationHeader))
        {
            return false;
        }

        const string prefix = "Bearer ";

        if (!authorizationHeader.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        var token = authorizationHeader[prefix.Length..].Trim();
        return token == _expectedToken;
    }
}

В appsettings.json вам понадобятся следующие ключи. (Токен носителя Opal — это тот, который вы используете при добавлении инструмента в Opal; Я покажу это ниже в посте.)

"Opal": {
  "BearerToken": "[TOKEN FOR OPAL]"
},
"CmsApi": {
  "BaseUrl": "https://localhost:6001"
},
"ExternalApi": {
  "BaseUrl": "https://localhost:7001"
}

Инструмент поиска данных о продукте

Процесс поиска продукта начинается с небольшой, сфокусированной входной модели в Models/ProductDataParameters.cs. Он просто отражает то, что нас волнует: Product имя, сведения о котором вызывающий абонент хочет получить.

Read more:  Таможенники Франции сделали неожиданное открытие среди картофеля, предназначенного для Ирландии.

Эта модель затем используется Tools/ProductDataTool.csкоторый зарегистрирован как инструмент Opal. Инструмент получает ProductDataParameters например, вводит IProductDataClientи перенаправляет запрос этому клиенту. Класс инструментов остается намеренно тонким: он организует вызов и формирует входные/выходные данные для Opal, но не имеет никакой реальной бизнес-логики.

Фактическое HTTP-взаимодействие находится в Services/ProductData/ProductDataClient.cs. Здесь мы обычно вызываем реальный внешний API, используя настроенный HttpClient. В примере клиент имеет заменяемую реализацию заполнителя: для известных продуктов, таких как sleeping-bag и tent он возвращает репрезентативный JSON, а для всего остального возвращается к простому элементу по умолчанию. Результаты отображаются в Models/ProductDataResult.cs поэтому инструмент всегда возвращает предсказуемый, структурированный ответ.

Когда вы будете готовы перейти от заглушенных данных к реальному API продукта, вам нужно всего лишь изменить реализацию клиента. Инструмент и его контракт могут остаться прежними.

ПродуктДанныеTool.cs

using System.ComponentModel;
using OpalToolsService.Models;
using OpalToolsService.Services.ProductData;
using Optimizely.Opal.Tools;

namespace OpalToolsService.Tools;

public class ProductDataTool(IProductDataClient client)
{
    [OpalTool(Name = "get-product-data")]
    [Description("Get product data from an external system using a product name and return the result.")]
    public async Task

ПродуктДанныеПараметры.cs

using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace OpalToolsService.Models;

public class ProductDataParameters
{
    [Required]
    [Description("Product to look up data in the external system.")]
    public string Product { get; set; } = string.Empty;
}

ПродуктДанныеРезультат.cs

namespace OpalToolsService.Models;

public class ProductDataResult
{
    public required string Product { get; init; }

    public string? Raw { get; init; }

    public DateTimeOffset RetrievedAt { get; init; }
}

Ипродуктдатаклиент.cs

using OpalToolsService.Models;

namespace OpalToolsService.Services.ProductData;

public interface IProductDataClient
{
    Task GetProductDataAsync(string product, CancellationToken cancellationToken);
}

ПродуктДанныеКлиент.cs

using OpalToolsService.Models;

namespace OpalToolsService.Services.ProductData;

public class ProductDataClient(HttpClient httpClient) : IProductDataClient
{
    public async Task GetProductDataAsync(string product, CancellationToken cancellationToken)
    {
        // Example placeholder call. Replace with your real API endpoint and model.
        // using var response = await httpClient.GetAsync($"/api/items/{Uri.EscapeDataString(key)}", cancellationToken);
        // response.EnsureSuccessStatusCode();
        // var payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
        // return payload ?? throw new InvalidOperationException("External system returned an empty payload.");
        
        await Task.CompletedTask; // remove when implementing real call

        // Dummy data for demonstration purposes
        string productData = product switch
        {
            "sleeping-bag" => """
                  {
                        "id": "SB-ARCTIC-LOFTE-300",
                        "sku": "SB-ARCTIC-LOFTE-300",
                        "name": "Nordvind Löfte 300 Sovsäck",
                        "shortDescription": "Varm tresäsongssovsäck för övernattning i svensk natur, komfort ned till -2 °C.",
                        "brand": "Nordvind",
                        "comfortTempC": -2,
                        "limitTempC": -8,
                        "season": "3-season (Nordic)",
                        "weightGrams": 1300,
                        "packedVolumeLiters": 13,
                        "priceSek": 1699,
                        "imageUrl": "https://cdn.example.com/images/sleepingbags/lof­te-300/main_front.jpg"
                          }
                  """,
            "tent" => """
                  {
                    "id": "TENT-NORDLYS-3P",
                    "sku": "TENT-NORDLYS-3P",
                    "name": "Nordvind Nordlys 3P Tält",
                    "shortDescription": "Stabilt 3-säsongstält för upp till tre personer, optimerat för blåsiga nätter i svensk natur.",
                    "brand": "Nordvind",
                    "capacityPersons": 3,
                    "season": "3-season (Nordic)",
                    "weightGrams": 3200,
                    "waterproofRatingMm": 3000,
                    "vestibuleAreaSqM": 1.8,
                    "priceSek": 4299,
                    "imageUrl": "https://cdn.example.com/images/tents/nordlys-3p/main_front.jpg"
                  }
                  """,
            _ => """
                 {
                   "id": "PAD-FJALL-LUFT-5",
                   "sku": "PAD-FJALL-LUFT-5",
                   "name": "Nordvind FjällLuft 5 Liggunderlag",
                   "shortDescription": "Lätt och varmt uppblåsbart liggunderlag med R-värde 4,5 för övernattningar i svensk vår, sommar och höst.",
                   "brand": "Nordvind",
                   "rValue": 4.5,
                   "lengthCm": 183,
                   "widthCm": 52,
                   "thicknessCm": 5,
                   "weightGrams": 520,
                   "priceSek": 1199,
                   "imageUrl": "https://cdn.example.com/images/pads/fjallluft-5/main_front.jpg"
                 }
                 """
        };

        return new ProductDataResult
        {
            Product = product,
            Raw = productData,
            RetrievedAt = DateTimeOffset.UtcNow
        };
    }
}

Инструмент создания страниц CMS

Инструмент CMS следует той же схеме, что и поиск продукта, но для создания контента. Входная модель в Models/CreateCmsPageParameters.cs определяет форму запроса, который отправляет агент Opal: обязательный Heading и Bodyплюс необязательный Preamble. Реализация инструмента в Tools/CmsPageTool.cs остается худым, просто принимая эти параметры, вводя ICmsClientи делегирование фактической работы этому клиенту.

Read more:  Келли и Шэрон Осборн радуют поклонников, снимая беззаботное видео для Омазе после того, как она защитилась от жестоких бодишеймеров.

Тяжелый подъем происходит в Services/Cms/CmsClient.csкоторый создает полезную нагрузку из CreateCmsPageParametersдобавляет необходимый заголовок токена API CMS, отправляет его в конечную точку CMS и десериализует ответ в CreateCmsPageResult. Эта результирующая модель (Models/CreateCmsPageResult.cs) предоставляет вызывающему URL-адрес созданной страницы, а также временную метку или аналогичные метаданные. С точки зрения агента Opal, контракт прост — «по заданному заголовку, преамбуле и телу создайте страницу и верните URL-адрес», — в то время как все детали маршрутизации, аутентификации и сериализации аккуратно хранятся внутри клиента.

В этом случае я только что создал простую конечную точку API на веб-сайте Optimizely 12 PaaS, которая создает страницу, когда инструмент ее вызывает. Я также покажу этот код.

CmsPageTool.cs

using System.ComponentModel;
using OpalToolsService.Models;
using OpalToolsService.Services.Cms;
using Optimizely.Opal.Tools;

namespace OpalToolsService.Tools;

public class CmsPageTool(ICmsClient cmsClient)
{
    [OpalTool(Name = "create-cms-page")]
    [Description("Creates a page in the CMS with a heading, preamble and body of text.")]
    public async Task
CreateCmsPageParameters.cs
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace OpalToolsService.Models;

public class CreateCmsPageParameters
{
    [Required]
    [Description("Page heading")]
    public string Heading { get; set; } = string.Empty;

    [Description("Short preamble for the page")]
    public string? Preamble { get; set; }

    [Required]
    [Description("Main body content of the page, e.g. HTML or markdown")]
    public string Body { get; set; } = string.Empty;
}
CreateCmsPageResult.cs
namespace OpalToolsService.Models;

public class CreateCmsPageResult
{
    public required string Url { get; init; }

    public DateTimeOffset CreatedAt { get; init; }
}
ICmsClient.cs
using OpalToolsService.Models;

namespace OpalToolsService.Services.Cms;

public interface ICmsClient
{
    Task CreatePageAsync(
        string heading,
        string? preamble,
        string body,
        CancellationToken cancellationToken);
}
CmsClient.cs
using System.Net.Http.Json;
using OpalToolsService.Models;

namespace OpalToolsService.Services.Cms;

public class CmsClient(HttpClient httpClient) : ICmsClient
{
    public async Task CreatePageAsync(string heading, string? preamble, string body, CancellationToken cancellationToken)
    {
        var payload = new { heading, preamble, body };

        using var request = new HttpRequestMessage(HttpMethod.Post, "[CMS API ENDPOINT HERE]");
        request.Content = JsonContent.Create(payload);
        request.Headers.Add("X-Api-Token", "[TOKEN FOR COMMUNICATING WITH CMS]");

        using var response = await httpClient.SendAsync(request, cancellationToken);
        response.EnsureSuccessStatusCode();

        var created = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);

        return created ?? throw new InvalidOperationException("CMS did not return a page result.");
    }
}

Тестирование конечных точек инструмента с помощью OpalToolsService.http

Чтобы быстро проверить работоспособность инструментов без использования Opal, вы можете вызвать конечные точки напрямую, используя .http файл. OpalToolsService.http Сценарий ниже позволяет вам нажать «Обнаружение», вызвать каждый инструмент с токеном-носителем и проверить, правильно ли отклоняются неавторизованные вызовы с помощью 401.

@host=http://localhost:5044
@token=074e44df-ec3f-4a72-bf52-b169de59ad19

### 1) Discovery (no bearer token)
GET {{host}}/opal/discovery
Accept: application/json

### 2) get product data tool (with bearer token)
POST {{host}}/opal/tools/get-product-data
Authorization: Bearer {{token}}
Content-Type: application/json
Accept: application/json

{
  "parameters": {
    "product": "tent"
  }
}

### 3) create-cms-page tool (with bearer token)
POST {{host}}/opal/tools/create-cms-page
Authorization: Bearer {{token}}
Content-Type: application/json
Accept: application/json

{
  "parameters": {
    "heading": "Test page created from Opal tool",
    "preamble": "This is a short intro/preamble created during local testing.",
    "body": "Here is the full body of the CMS page. You can put markdown, HTML, or plain text here."
  }
}

### 5) get product data without token (should return 401)
POST {{host}}/opal/tools/get-product-data
Content-Type: application/json
Accept: application/json

{
  "parameters": {
    "product": "should-fail-unauthorized"
  }
}

Конечная точка CMS

Интеграция CMS в этом примере намеренно носит общий характер. Конечная точка API — это просто «CMS, которая может принимать HTTP-вызовы». CmsClient отправляет полезную нагрузку JSON (заголовок, преамбулу, тело) на настроенный URL-адрес и добавляет любой токен API, который ожидает CMS. Это означает, что вы не привязаны к конкретному поставщику или продукту — любую CMS или канал контента, который предоставляет конечную точку HTTP для создания контента, можно подключить, настроив базовый URL-адрес, маршрут и соглашения заголовков аутентификации в клиенте, не меняя инструмент Opal или его контракт. Но в данном случае я только что создал контроллер API в решении Optimizely 12 для целей тестирования.

[ApiController]
[Route("api/test")]
public class TestApiController(IContentRepository contentRepository, UrlResolver urlResolver) : ControllerBase
{
    [HttpPost]
    [Route("article")]
    public IActionResult CreateArticle([FromBody] CreatePageRequest request)
    {
        var expectedToken = "[TOKEN FOR COMMUNICATING WITH CMS]";
        var requestToken = Request.Headers["X-Api-Token"].ToString();
        if (string.IsNullOrEmpty(requestToken) || requestToken != expectedToken)
        {
            return Unauthorized("Invalid token.");
        }

        if (string.IsNullOrWhiteSpace(request.Heading))
        {
            return BadRequest("Heading is required.");
        }

        if (string.IsNullOrWhiteSpace(request.Body))
        {
            return BadRequest("Body is required.");
        }

        var parent = ContentReference.StartPage;
        var page = contentRepository.GetDefault(parent);

        page.Name = request.Heading;
        page.Heading = request.Heading;
        page.PageHeader.Preamble = request.Preamble ?? string.Empty;
        page.PlainText = new XhtmlString(request.Body);

        var savedRef = contentRepository.Save(
            page,
            SaveAction.Publish,
            AccessLevel.NoAccess);

        var savedPage = contentRepository.Get(savedRef);
        var publicUrl = urlResolver.GetUrl(savedRef);

        var response = new CreatePageResponse
        {
            Url = publicUrl,
            Created = savedPage.Created,
            Id = savedPage.ContentLink.ID
        };

        return Ok(response);
    }
}

public class CreatePageRequest
{
    public string? Heading { get; set; }
    public string? Preamble { get; set; }
    public string? Body { get; set; }
}

public class CreatePageResponse
{
    public string? Url { get; set; }
    public DateTime Created { get; set; }
    public int Id { get; set; }
}

Как это выглядит в Opal

После запуска API вы можете зарегистрировать его как инструмент в Opal. Выберите имя, укажите его на своем хостинге /opal конечную точку и вставьте токен носителя из appsettings.json. После сохранения инструменты должны появиться в списке, чтобы вы могли прикрепить их к агентам и опробовать их из пользовательского интерфейса Opal. Если вы измените API во время тестирования, перейдите в раздел «Реестры» и нажмите «Синхронизировать», чтобы обновить определение инструмента.

Read more:  Некоторые ветеринары предлагают для этого усыпить котят, но не верьте лжи

Список инструментов, добавить реестр инструментов

Добавить реестр инструментов

После добавления он должен появиться в списке.

Реестр инструментов.

Если вам необходимо обновить инструмент во время тестирования, перейдите к Реестры и нажмите Синхронизировать.

Вопрос в чате.

Результат при использовании средства Product Tool.

Вопрос в чате.

И страница создается в CMS.

Заключительные мысли

Возвращаясь назад, этот пример представляет собой решение с поддержкой Opal, которое предоставляет инструменты под /opal/*блокируется /opal/tools* с небольшим количеством промежуточного программного обеспечения плюс BearerTokenValidatorи взаимодействует с внешними системами через типизированные HTTP-клиенты. Кроме того, мы добавили две конкретные возможности: ProductDataTool для получения данных о продукте и CmsPageTool для создания страниц CMS. Структура намеренно проста: инструменты ориентированы на входные и выходные данные, клиенты обрабатывают неприятные детали, а модели обеспечивают честность контрактов.

Основные идеи достаточно многоразовые: сохраняйте аутентификацию на периферии, сохраняйте тонкие инструменты и не бойтесь отключать внешние системы, пока остальная часть архитектуры устанавливается. Отсюда добавление дополнительных инструментов — проверок запасов, истории заказов, различных типов контента — по сути, означает «промыть и повторить».

И это на самом деле все: небольшой скучный API, который очень хорошо работает с Opal.

Читайте также

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.