Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/planning/backend-technical-spec.md

Large diffs are not rendered by default.

49 changes: 44 additions & 5 deletions src/plugins/FluentCMS.Plugins.AiAssist/AiAssistService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ public sealed record AiSeoSuggestion(string MetaTitle, string MetaDescription);

public sealed record AiUsageInfo(string Provider, string Model, long PromptTokens, long CompletionTokens, long DurationMs);

// The answer of GET /ai/fields: whether the site names a provider, and by content type the opted-in fields that the caller
// may run actions on in Locale, the locale of the request. Nothing of the provider's configuration.
public sealed record AiFields(bool Enabled, string Locale, IReadOnlyDictionary<string, IReadOnlyList<string>> Fields);

// A call that passed every check and is ready for its provider.
internal sealed record AiCall(
string Action,
Expand Down Expand Up @@ -77,6 +81,37 @@ internal sealed class AiAssistService(

private const string RichTextFieldType = "richText";

// The fields that pass the checks of PrepareAsync in the locale of the request, so that an editor offers the actions
// only where they work. A field the caller may set on a new entry only is left out, since PrepareAsync refuses it too.
public async Task<AiFields> FieldsAsync(CancellationToken cancellationToken)
{
var operation = operations.Current ?? throw new InvalidOperationException("The AI fields are read inside an operation, which names the site and the caller.");
await permissions.RequireAsync(AiPermissions.Use, operation.Site, cancellationToken);

var current = await settings.GetAsync(operation.Site, cancellationToken);
var enabled = current.Provider != AiProvider.None && !AiProviders.IsRemoved(current.Provider);
Dictionary<string, IReadOnlyList<string>> fields = new(StringComparer.Ordinal);
if (enabled)
{
var held = await access.ContentAsync(cancellationToken);
foreach (var (name, typeAccess) in held.Types)
{
if (schema.FindContentType(name) is not { } type)
{
continue;
}

var offered = type.Fields.Where(field => IsOptedIn(current, type, field) && MayUpdate(typeAccess, field)).Select(field => field.Name).ToList();
if (offered.Count > 0)
{
fields[type.Name] = offered;
}
}
}

return new AiFields(enabled, operation.Locale, fields);
}

public async Task<AiCall> PrepareAsync(string action, AiActionRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
Expand All @@ -97,8 +132,7 @@ public async Task<AiCall> PrepareAsync(string action, AiActionRequest request, C
var current = await settings.GetAsync(site, cancellationToken);
var type = schema.FindContentType(request.Type.Trim());
var field = type?.FindField(request.Field.Trim());
if (type is null || field is null || !TakesActions(field)
|| !current.Fields.TryGetValue(type.Name, out var optedIn) || !optedIn.Contains(field.Name, StringComparer.Ordinal))
if (type is null || field is null || !IsOptedIn(current, type, field))
{
throw new PluginException(AiErrorCodes.FieldNotOptedIn, "The AI settings of the site do not opt this field in. A field that takes AI actions is a text, long text or rich text field of a content type, named under 'fields'.");
}
Expand All @@ -113,7 +147,7 @@ public async Task<AiCall> PrepareAsync(string action, AiActionRequest request, C
}

var held = await AccessInAsync(operation, type.Localized ? locale : operation.Locale, cancellationToken);
if (!held.Types.TryGetValue(type.Name, out var typeAccess) || !typeAccess.Update || !typeAccess.UpdatableFields.Contains(field.Name))
if (!held.Types.TryGetValue(type.Name, out var typeAccess) || !MayUpdate(typeAccess, field))
{
throw new ContentAccessDeniedException(type.Name, "update");
}
Expand Down Expand Up @@ -170,13 +204,13 @@ public async Task<AiCall> PrepareAsync(string action, AiActionRequest request, C
message.Add(new TextContent(text));
}

// Whichever factory fills the slot: a host's own may not look at the provider.
// Whichever factory fills the slot: a host's own may not look at the provider, and none is off as FieldsAsync says.
if (AiProviders.IsRemoved(current.Provider))
{
throw AiProviders.Refuse(logger, site, current.Provider);
}

var client = await clients.CreateAsync(site, current, cancellationToken)
var client = (current.Provider == AiProvider.None ? null : await clients.CreateAsync(site, current, cancellationToken))
?? throw new PluginException(AiErrorCodes.NoProvider, "The site has no AI provider. An administrator names one in the AI settings of the site.");

if (limits.Take(site, operation.Principal, current.CallsPerMinute) is { } refusal)
Expand Down Expand Up @@ -300,6 +334,11 @@ public async Task<AiActionResult> RunAsync(AiCall call, Func<string, Cancellatio
private static bool TakesActions(SchemaField field) =>
TextFieldTypes.Contains(field.FieldType, StringComparer.Ordinal) && !field.Multiple && !field.Hidden && !field.ReadOnly;

private static bool IsOptedIn(AiSettings settings, SchemaType type, SchemaField field) =>
TakesActions(field) && settings.Fields.TryGetValue(type.Name, out var optedIn) && optedIn.Contains(field.Name, StringComparer.Ordinal);

private static bool MayUpdate(ContentTypeAccess access, SchemaField field) => access.Update && access.UpdatableFields.Contains(field.Name);

private static ValidationError Invalid(string path, string message) => new(path, AiErrorCodes.InvalidRequest, message);

// What the caller may do with content in a locale. IEffectiveAccess answers for the operation, so the question is
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/FluentCMS.Plugins.AiAssist/AiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public static RouteGroupBuilder MapAiEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/actions/{action}", ActAsync);

group.MapGet("/fields", async (AiAssistService ai, CancellationToken cancellationToken) =>
Results.Ok(await ai.FieldsAsync(cancellationToken))).Produces<AiFields>();

group.MapGet("/usage", async (string? principalId, string? action, string? from, string? to, string? page, string? pageSize, AiUsageService usage, CancellationToken cancellationToken) =>
Results.Ok(await usage.QueryAsync(new AiUsageQuery(principalId, action, from, to, page, pageSize), cancellationToken)));

Expand Down
20 changes: 19 additions & 1 deletion src/plugins/FluentCMS.Plugins.AiAssist/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,24 @@ The checks run in this order:

With `documentId` the content service decides whether the caller may read the draft, and with `mediaId` the media service whether it may read the image.

### Which fields take actions

`GET /api/management/v1/ai/fields`, with the permission `ai.use`, tells an editor where to offer the actions, without the settings area and its `settings.ai.manage`:

```json
{
"enabled": true,
"locale": "en",
"fields": { "article": ["title", "summary"] }
}
```

- `enabled` is whether the site names a provider the plugin has. A site whose `provider` is `none`, or a removed one (`anthropic`, `ollama`), is off and lists no field, and its actions are refused (`FCMS1682`, `FCMS1692`).
- `locale` is the locale of the request, which `?locale=` or `X-Cms-Locale` names.
- `fields` maps each content type to the fields, in the order of the schema, that pass the checks above for the caller in that locale: opted in, a field that takes actions, and one the caller may update. A type the caller may not read is left out, and so is a field it may set on a new entry only, which an action refuses too.

The answer holds nothing of the provider's configuration: not the provider, the model, the endpoint or the key, nor whether a key is set. A provider that is not configured well enough to answer is learnt at the call (`FCMS1684`, `FCMS1685`).

### Limits

- The text has at most `MaxInputLength` characters (`FCMS1686`).
Expand Down Expand Up @@ -190,7 +208,7 @@ builder.Services.AddFluentCms(cms =>
});
```

The factory is a singleton and owns its clients: the plugin calls a client and does not dispose it. Returning `null` leaves AI assist off for that site (`FCMS1682`). The allow-list is the default factory's, so a factory of the host's own decides itself which endpoints it calls. The permission, the opt-in, the limits and the usage log stay the plugin's.
The factory is a singleton and owns its clients: the plugin calls a client and does not dispose it. Returning `null` leaves AI assist off for that site (`FCMS1682`). The plugin does not ask the factory for a site whose `provider` is `none`, or a removed one, and refuses the call itself, so such a site is off whatever the factory would give. The allow-list is the default factory's, so a factory of the host's own decides itself which endpoints it calls. The permission, the opt-in, the limits and the usage log stay the plugin's.

The plugin ends a call at the site's `requestTimeout`, and when the caller goes away, through the cancellation token it passes to the client, so the host's client must honour that token: one that ignores it can run past `requestTimeout`. The client must also have no network timeout of its own that is shorter than `requestTimeout`, which goes up to 600 seconds. The clients built on `System.ClientModel`, such as OpenAI's, stop a request after 100 seconds unless told otherwise, and a call that such a timeout ends is answered as a failure of the provider (`FCMS1689`), not as a timeout (`FCMS1688`). The default factory gives its client no timeout of its own.

Expand Down
170 changes: 170 additions & 0 deletions tests/FluentCMS.Plugins.AiAssist.Tests/AiFieldsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System.Net;
using System.Text.Json.Nodes;
using FluentCMS.Core.Authorization;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
using Shouldly;
using Xunit;
using static FluentCMS.Plugins.AiAssist.Tests.AiTesting;

namespace FluentCMS.Plugins.AiAssist.Tests;

// GET /ai/fields, which tells an editor where the actions work, by the checks of POST /ai/actions/{action}.
public sealed class AiFieldsTests : IClassFixture<AiHost>
{
private readonly AiHost _ai;

public AiFieldsTests(AiHost ai)
{
_ai = ai;
_ai.Chat.Reset();
}

[Fact]
public async Task AnEditorGetsTheOptedInFieldsItMayUpdateAndNotThoseItMayOnlyRead()
{
// rating takes no actions and missing is no field of the type, so neither is listed.
var site = await _ai.SiteAsync(settings => settings.Fields["article"] = ["body", "summary", "title", "rating", "missing"]);
using var admin = await _ai.AdminAsync(site);
using var editor = await _ai.CallerAsync(
site,
(AiPermissions.Use, null),
("content.article.read", null),
("content.article.update", new PermissionConstraints { Fields = ["title", "notes", "rating"] }));

var all = await FieldsAsync(admin);
var some = await FieldsAsync(editor);

all["enabled"]!.GetValue<bool>().ShouldBeTrue();
all["locale"]!.GetValue<string>().ShouldBe("en");
all["fields"]!.ToJsonString().ShouldBe("""{"article":["title","summary","body"]}""");
some["fields"]!.ToJsonString().ShouldBe("""{"article":["title"]}""");

// The actions agree.
using var listed = await ActAsync(editor, "title", new { type = "article", field = "title", text = "Ships are big boats." });
using var readOnly = await ActAsync(editor, "shorten", new { type = "article", field = "summary", text = "Ships are big boats." });
using var notOptedIn = await ActAsync(editor, "rewrite", new { type = "article", field = "notes", text = "Ships are big boats." });
listed.StatusCode.ShouldBe(HttpStatusCode.OK);
(await RefusedAsync(readOnly, HttpStatusCode.Forbidden)).ShouldBe("FCMS3103");
(await RefusedAsync(notOptedIn, HttpStatusCode.Forbidden)).ShouldBe("FCMS1683");
}

// A type the caller may not read is not listed even when it may update it, and a field it may set on a new entry only
// is not listed either: the actions refuse both.
[Fact]
public async Task ATypeTheCallerMayNotReadAndAFieldItMayOnlyCreateAreLeftOut()
{
var site = await _ai.SiteAsync();
using var readers = await _ai.CallerAsync(site, (AiPermissions.Use, null), ("content.article.read", null));
using var blind = await _ai.CallerAsync(site, (AiPermissions.Use, null), ("content.article.update", null));
using var creators = await _ai.CallerAsync(site, (AiPermissions.Use, null), ("content.article.read", null), ("content.article.create", null));

foreach (var caller in (HttpClient[])[readers, blind, creators])
{
var answer = await FieldsAsync(caller);
answer["enabled"]!.GetValue<bool>().ShouldBeTrue();
answer["fields"]!.ToJsonString().ShouldBe("{}");

using var refused = await ActAsync(caller, "title", new { type = "article", field = "title", text = "Ships are big boats." });
(await RefusedAsync(refused, HttpStatusCode.Forbidden)).ShouldBe("FCMS3103");
}

_ai.Chat.Calls.ShouldBeEmpty();
}

[Fact]
public async Task TheFieldsAreThoseTheCallerMayUpdateInTheLocaleOfTheRequest()
{
var site = await _ai.SiteAsync(null, "fr");
using var editor = await _ai.CallerAsync(
site,
(AiPermissions.Use, null),
("content.article.read", null),
("content.article.update", new PermissionConstraints { Locales = ["fr"] }));

var english = await FieldsAsync(editor);
var french = await FieldsAsync(editor, "fr");

english["locale"]!.GetValue<string>().ShouldBe("en");
english["fields"]!.ToJsonString().ShouldBe("{}");
french["locale"]!.GetValue<string>().ShouldBe("fr");
french["fields"]!.ToJsonString().ShouldBe("""{"article":["title","summary","body"]}""");
}

[Fact]
public async Task ASiteWithoutAProviderHasAiAssistOff()
{
var none = await _ai.SiteAsync(settings => settings.Provider = AiProvider.None);
var bare = await _ai.BareSiteAsync();
var removed = await _ai.BareSiteAsync();
await _ai.StoreSettingsAsync(removed, """{"provider":"anthropic","model":"a-model","fields":{"article":["title"]}}""");

foreach (var site in (Guid[])[none, bare, removed])
{
using var admin = await _ai.AdminAsync(site);
var answer = await FieldsAsync(admin);
answer["enabled"]!.GetValue<bool>().ShouldBeFalse();
answer["fields"]!.ToJsonString().ShouldBe("{}");
}

using var off = await _ai.AdminAsync(none);
using var refused = await ActAsync(off, "title", new { type = "article", field = "title", text = "Ships are big boats." });
(await RefusedAsync(refused, HttpStatusCode.Conflict)).ShouldBe("FCMS1682");
}

[Fact]
public async Task ACallerWithoutAiUseIsForbidden()
{
var site = await _ai.SiteAsync();
using var editor = await _ai.CallerAsync(site, ("content.article.read", null), ("content.article.update", null));
using var anonymous = _ai.Host.CreateClient();
anonymous.DefaultRequestHeaders.Add("X-Cms-Site", site.ToString());

using var forbidden = await editor.GetAsync(new Uri(Fields, UriKind.Relative), Token);
using var notSignedIn = await anonymous.GetAsync(new Uri(Fields, UriKind.Relative), Token);

(await RefusedAsync(forbidden, HttpStatusCode.Forbidden)).ShouldBe("FCMS3103");
notSignedIn.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task TheAnswerHoldsNothingOfTheProvidersConfiguration()
{
var site = await _ai.SiteAsync(settings =>
{
settings.Provider = AiProvider.OpenAiCompatible;
settings.Model = "model-of-the-site";
settings.Endpoint = "https://models.example.com/v1";
settings.ApiKey = "key-that-is-never-returned";
});
using var admin = await _ai.AdminAsync(site);

using var response = await admin.GetAsync(new Uri(Fields, UriKind.Relative), Token);
var raw = await response.Content.ReadAsStringAsync(Token);

response.StatusCode.ShouldBe(HttpStatusCode.OK, raw);
JsonNode.Parse(raw)!.AsObject().Select(property => property.Key).ShouldBe(["enabled", "locale", "fields"]);
foreach (var held in (string[])["model-of-the-site", "models.example.com", "key-that-is-never-returned", "openAiCompatible", "provider", "model", "endpoint", "apiKey"])
{
raw.ShouldNotContain(held, Case.Insensitive);
}
}

[Fact]
public async Task TheOpenApiDocumentListsTheRouteWithItsBody()
{
var provider = _ai.Services.GetRequiredKeyedService<IOpenApiDocumentProvider>("v1");
var document = JsonNode.Parse(await (await provider.GetOpenApiDocumentAsync(Token)).SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1, Token))!;

var answer = document["paths"]![Fields]!["get"]!["responses"]!["200"]!["content"]!["application/json"]!["schema"]!;
answer["$ref"]!.GetValue<string>().ShouldBe("#/components/schemas/AiFields");
document["components"]!["schemas"]!["AiFields"]!["properties"]!.AsObject().Select(property => property.Key).ShouldBe(["enabled", "locale", "fields"]);
}

private static async Task<JsonNode> FieldsAsync(HttpClient client, string? locale = null)
{
using var response = await client.GetAsync(new Uri(Fields + (locale is null ? "" : $"?locale={locale}"), UriKind.Relative), Token);
return await JsonAsync(response);
}
}
20 changes: 20 additions & 0 deletions tests/FluentCMS.Plugins.AiAssist.Tests/AiSiteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ public async Task TheEndpointsAnswerNotFoundForASiteWhereThePluginIsDisabled()

using var action = await ActAsync(there, "rewrite", body);
using var usage = await there.GetAsync(new Uri(Usage, UriKind.Relative), Token);
using var fields = await there.GetAsync(new Uri(Fields, UriKind.Relative), Token);
using var notSignedIn = await ActAsync(anonymous, "rewrite", body);
using var elsewhere = await ActAsync(here, "rewrite", body);

(await RefusedAsync(action, HttpStatusCode.NotFound)).ShouldBe("FCMS1550");
(await RefusedAsync(usage, HttpStatusCode.NotFound)).ShouldBe("FCMS1550");
(await RefusedAsync(fields, HttpStatusCode.NotFound)).ShouldBe("FCMS1550");
notSignedIn.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
elsewhere.StatusCode.ShouldBe(HttpStatusCode.OK);
_ai.Chat.Calls.ShouldHaveSingleItem();
Expand Down Expand Up @@ -185,6 +187,24 @@ public async Task ASiteThatNamesARemovedProviderIsRefusedWhateverFillsTheSlot()
(await _ai.UsageAsync(site)).ShouldBeEmpty();
}

// A host's own factory may hand out a client whatever the settings name, so the plugin refuses none itself, before it
// asks the slot, and the actions agree with GET /ai/fields that the site is off.
[Fact]
public async Task ASiteWithNoProviderIsOffWhateverFillsTheSlot()
{
var site = await _ai.SiteAsync(settings => settings.Provider = AiProvider.None);
using var admin = await _ai.AdminAsync(site);
_ai.Chat.ForEverySite = true;

using var refused = await ActAsync(admin, "title", new { type = "article", field = "title", text = "Ships are big boats." });
using var fields = await admin.GetAsync(new Uri(Fields, UriKind.Relative), Token);

(await RefusedAsync(refused, HttpStatusCode.Conflict)).ShouldBe("FCMS1682");
(await JsonAsync(fields))["enabled"]!.GetValue<bool>().ShouldBeFalse();
_ai.Chat.Calls.ShouldBeEmpty();
(await _ai.UsageAsync(site)).ShouldBeEmpty();
}

private static async Task<JsonNode> ReadAsync(HttpClient client)
{
using var response = await client.GetAsync(new Uri(Settings, UriKind.Relative), Token);
Expand Down
Loading
Loading