diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs index 926f0fa8812..887426af88b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs @@ -19,6 +19,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -92,6 +94,63 @@ public static DecisionDefinition Create( return builder.Build(); } + private static AIFunctionFactoryOptions GetFunctionOptions( + AIFunctionFactoryOptions? functionOptions, + JsonSerializerOptions serializerOptions) + { + if (functionOptions is null) + { + return new AIFunctionFactoryOptions { SerializerOptions = serializerOptions }; + } + + if (functionOptions.SerializerOptions is not null) + { + return functionOptions; + } + + return new AIFunctionFactoryOptions + { + SerializerOptions = serializerOptions, + JsonSchemaCreateOptions = functionOptions.JsonSchemaCreateOptions, + Name = functionOptions.Name, + Description = functionOptions.Description, + AdditionalProperties = functionOptions.AdditionalProperties, + ConfigureParameterBinding = functionOptions.ConfigureParameterBinding, + MarshalResult = functionOptions.MarshalResult, + ExcludeResultSchema = functionOptions.ExcludeResultSchema, + }; + } + + /// Creates an that evaluates this definition for a caller-owned state type. + /// The application-owned state type exposed as the function's input. + /// The caller-owned decision client. + /// Optional standard function factory options. + /// + /// Optional decision options shallow-copied during construction and cloned for each invocation. Nested values and + /// the raw-options factory remain caller/provider-owned. + /// + /// + /// Optional explicit JSON contract for . When omitted, the definition's serializer + /// options must contain the state contract. + /// + /// A function whose only model-facing input is and whose result is . + public AIFunction AsAIFunction( + IDecisionClient client, + AIFunctionFactoryOptions? functionOptions = null, + DecisionOptions? decisionOptions = null, + JsonTypeInfo? stateTypeInfo = null) + { + _ = Throw.IfNull(client); + + stateTypeInfo ??= GetStateTypeInfo(); + DecisionOptions? decisionOptionsSnapshot = decisionOptions?.Clone(); + DecisionFunctionInvoker invoker = new(client, this, stateTypeInfo, decisionOptionsSnapshot); + + return AIFunctionFactory.Create( + invoker.InvokeAsync, + GetFunctionOptions(functionOptions, stateTypeInfo.Options)); + } + internal DecisionResponse Bind(DecisionResponse response, DecisionRequest expectedRequest) { _ = Throw.IfNull(response); @@ -186,6 +245,41 @@ internal DecisionResponse Bind(DecisionResponse response, DecisionReque return new DecisionResponse(result, response, this); } + private JsonTypeInfo GetStateTypeInfo() + { + return SerializerOptions.GetTypeInfo(typeof(TState)) is JsonTypeInfo stateTypeInfo + ? stateTypeInfo + : throw new NotSupportedException( + $"The configured JSON options do not contain a contract for state type '{typeof(TState)}'."); + } + + private sealed class DecisionFunctionInvoker( + IDecisionClient client, + DecisionDefinition definition, + JsonTypeInfo stateTypeInfo, + DecisionOptions? decisionOptionsSnapshot) + { + private readonly IDecisionClient _client = client; + private readonly DecisionDefinition _definition = definition; + private readonly JsonTypeInfo _stateTypeInfo = stateTypeInfo; + private readonly DecisionOptions? _decisionOptionsSnapshot = decisionOptionsSnapshot; + + [DisplayName("evaluate_decision")] + [Description("Evaluate the declared decision for one application state.")] + public async Task InvokeAsync( + [Description("The application state for the declared decision.")] TState state, + CancellationToken cancellationToken) + { + DecisionResponse response = await _client.GetResponseAsync( + state, + _stateTypeInfo, + _definition, + _decisionOptionsSnapshot?.Clone(), + cancellationToken).ConfigureAwait(false); + return response.Result; + } + } + internal IReadOnlyDictionary GetDistribution( DecisionResponse response, Expression> selector) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index ca0e7d73916..d2e80a86379 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -1,12 +1,12 @@ # Experimental decision abstractions -The `Microsoft.Extensions.AI.Abstractions` decision contracts provide a small provider-neutral proposed Layer 1 capability for heterogeneous binary, choice, and ordinal-score questions. Requests preserve caller-owned question and candidate IDs and order; responses preserve complete probability observations, score precision, provenance, usage, and provider extensions without applying thresholds, calibration, probability repair, retry policy, or a joint-distribution promise. +The `Microsoft.Extensions.AI.Abstractions` decision contracts provide a small provider-neutral Layer 1 capability for heterogeneous binary, choice, and ordinal-score questions. Requests preserve caller-owned question and candidate IDs and order. Responses retain complete probability observations, score precision, provenance, usage, and provider evidence without applying thresholds, calibration, probability repair, retry policy, or a joint-distribution promise. ## A business example -Suppose a retailer annotates customer-support tickets. The application wants a normal .NET category, a probability that the ticket is a refund request, and the complete provider evidence for reporting, human review, or downstream training. It is not automatically authorizing a refund, choosing a route, or treating a probability as a calibrated business decision. +Suppose a retailer annotates customer-support tickets. The application wants a normal .NET category, a probability that the ticket is a refund request, and complete provider evidence for reporting or human review. It is not automatically authorizing a refund, choosing a route, or treating a probability as a calibrated business decision. -The domain types remain ordinary application-owned types. The standard `DescriptionAttribute` on the positional record parameters supplies business instructions for the selected result properties, while the enum members supply separate candidate criteria: +The domain types remain ordinary application-owned types: ```csharp #pragma warning disable MEAI001 @@ -32,120 +32,275 @@ public sealed record TicketAnalysis( [Description("Is the customer requesting a refund or payment reversal?")] double RefundRequestProbability); ``` -The business call is ordinary application code. Assume the host supplies the `IDecisionClient client`; the declaration is reusable and explicit. `Choice` declares the closed enum domain and uses its configured JSON enum names as exact candidate IDs; `BinaryProbability` maps the binary answer's `TrueProbability` to a `double`. No handwritten question IDs, enum dictionary, result mapper, JSON schema, or feature schema is required for this ordinary flow: +The host explicitly supplies an `IDecisionClient`. It is provider-neutral; MEAI does not create a default implementation and an arbitrary `IChatClient` is not automatically a decision client. Provider acquisition is intentionally separate from platform composition: the provider owner supplies the configured client entry point, while these examples consume only the interface. Jev/OllamaSharp and the Ollaya, Laya, Julia, and Qwen adapters remain provider-owned and are not shipped, repinned, or executed by this repository. A deterministic test double is used for the platform proofs. -```csharp -using Microsoft.Extensions.AI; +An external, unpublished Julia/MEAI bridge was used as historical implementation evidence. It is not a released dependency or a runnable setup promised by this README. The provider owner's desired native shape is a separate, prospective entry point such as `JuliaDecisionClient.LoadFromDirectory(assetDirectory)`; that client is not implemented or available in this package. -var definition = DecisionDefinition.Create(builder => -{ - builder.Choice(result => result.Category); - builder.BinaryProbability(result => result.RefundRequestProbability); -}); +The reusable typed declaration is ordinary application code: -var ticket = new SupportTicket( +```csharp +DecisionDefinition definition = + DecisionDefinition.Create(builder => + { + builder.Choice(result => result.Category); + builder.BinaryProbability(result => result.RefundRequestProbability); + }); + +SupportTicket ticket = new( "I was charged twice. Please refund the duplicate payment."); -var response = await client.GetResponseAsync(ticket, definition); +DecisionResponse response = + await client.GetResponseAsync(ticket, definition); TicketAnalysis analysis = response.Result; DecisionResponse evidence = response.Evidence; ``` -When the complete choice evidence is useful for reporting or review, the typed distribution remains available: +`response.Evidence` contains the complete neutral response, including every category probability and the binary observation. It is separate from any chat usage or business action. The mapped result is not a hidden threshold or calibration claim. + +For an explicit JSON boundary, supply source-generated metadata to both state and result contracts: ```csharp -var categoryDistribution = response.GetDistribution(value => value.Category); +DecisionDefinition metadataDefinition = + DecisionDefinition.Create( + TicketJsonContext.Default.TicketAnalysis, + builder => + { + builder.Choice(result => result.Category); + builder.BinaryProbability(result => result.RefundRequestProbability); + }); + +DecisionResponse metadataResponse = + await client.GetResponseAsync( + ticket, + TicketJsonContext.Default.SupportTicket, + metadataDefinition); + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(SupportTicket))] +[JsonSerializable(typeof(TicketCategory))] +[JsonSerializable(typeof(TicketAnalysis))] +public partial class TicketJsonContext : JsonSerializerContext; ``` -The same definition can include an explicitly ordered score rubric, and heterogeneous declarations remain independent: +`JsonTypeInfo` describes a serialized state or result shape. It does not replace decision instructions, exact IDs, complete distributions, or stale-domain checks. JSON enum naming controls candidate IDs, while decision correlation remains exact and rejects unknown or changed IDs. + +## Layer 2 interoperability: two independent paths + +Layer 2 keeps function composition and chat routing independent. The function examples use either the stock `AIFunctionFactory` directly or the thin definition-based `AsAIFunction` convenience member. Neither path discovers a provider or takes a client from model-supplied arguments. + +### 1. Annotated application method to `AIFunctionFactory` + +An ordinary annotated method can close over a host-injected client and definition. The client, definition, endpoint, model, and options are not model-supplied tool arguments: ```csharp -var definitionWithScore = DecisionDefinition.Create(builder => +// Inside an existing application type, these are ordinary host-owned fields: +// IDecisionClient client; +// DecisionDefinition definition; +// Action? recordEvidence; + +[DisplayName("analyze_support_ticket")] +[Description("Annotate one support ticket.")] +public async Task AnalyzeSupportTicketAsync( + [Description("The ticket to classify.")] SupportTicket ticket, + CancellationToken cancellationToken) { - builder.Choice(result => result.Category, "Classify the customer's main concern."); - builder.BinaryProbability(result => result.RefundRequestProbability); - // A score property would be declared with an explicit DecisionScoreLevel list: - // builder.Score(result => result.Satisfaction, [new("low", "Low"), new("high", "High")]); - // builder.ExpectedScore(result => result.Satisfaction, [new("low", "Low"), new("high", "High")]); -}); + DecisionResponse response = await client.GetResponseAsync( + ticket, + definition, + cancellationToken: cancellationToken); + + recordEvidence?.Invoke(response.Evidence); + return response.Result; +} + +AIFunction analyzeTicket = AIFunctionFactory.Create( + AnalyzeSupportTicketAsync, + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket", + Description = "Annotate a support ticket for review.", + }); + +IChatClient chatClient = configuredChatClient + .AsBuilder() + .UseFunctionInvocation() + .Build(); + +ChatResponse chatResponse = await chatClient.GetResponseAsync( + messages, + new ChatOptions { Tools = [analyzeTicket] }, + cancellationToken); ``` -`Score` projects only a provider-reported native ordinal score and fails explicitly when that scalar is absent. `ExpectedScore` projects the unmodified ordinal expectation calculated from the complete observed distribution, so the two choices remain distinguishable; after probability rounding, that observed expectation can fall outside the ordinal bounds and is not repaired. Every probability is conditioned on the full request state and question set; declaring questions independently does not promise marginal invariance, independence, a joint distribution, or calibration. +The result-only method returns the business result by default. `recordEvidence` is an explicit host-owned per-invocation callback; it is not a shared last-response slot or automatic analytics. If selected metadata is useful, define an application DTO and return only the mapped result plus `response.Evidence.Provenance` and `response.Evidence.Usage`. Do not return raw state, provider extensions, credentials, reasoning text, or unrestricted dictionaries. Each invocation performs one logical decision operation, cancellation and provider/mapper/host failures propagate, and decision usage remains separate from chat usage. The callback must retain or process each invocation itself; the platform does not retain a last response. + +`AIFunctionFactory` options can override method attributes. Its schema contains only the application input (`ticket` here); `client`, `definition`, and `CancellationToken` are infrastructure, not model-facing parameters. The middleware example above exercises normal function invocation; direct `AIFunction.InvokeAsync` is sufficient for a platform-only proof and should not be described as a chat-loop proof. -Reported scalar consistency uses normalized latent feasibility within clipped rounding intervals; the observed probabilities are never normalized or rewritten. Typed binding verifies the actual CLR property values after materialization, independently of write-only JSON formatting. When options omit a resolver, the definition supplies the MEAI default resolver, while explicit caller resolvers and converters remain unchanged. +### 1a. Definition shorthand -At an explicit JSON boundary, callers can use source-generated metadata for both state and result contracts. The explicit generic arguments are optional for inference, but make the boundary visible: +When the application does not need a custom evidence callback, the definition can create the same ordinary `AIFunction` directly. The generic state type is explicit, while the result type remains the `TResult` from the definition: ```csharp -var metadataDefinition = - DecisionDefinition.Create(TicketJsonContext.Default.TicketAnalysis, builder => +AIFunction analyzeTicket = definition.AsAIFunction( + client, + new AIFunctionFactoryOptions { - builder.Choice(result => result.Category); - builder.BinaryProbability(result => result.RefundRequestProbability); + Name = "analyze_support_ticket", + Description = "Annotate one support ticket for review.", + SerializerOptions = TicketJsonContext.Default.Options, }); - -var metadataResponse = await client.GetResponseAsync( - ticket, - TicketJsonContext.Default.SupportTicket, - metadataDefinition); ``` -When the application already has a JSON state snapshot, the result-only overload keeps the same local binding behavior without re-serializing the state: +The helper exposes only one model-facing `state` parameter and returns `TicketAnalysis`. It does not return `DecisionResponse`, evidence, provenance, usage, raw provider data, or a credentials/options object. Use the annotated-method path when the host needs an explicit per-invocation evidence callback. + +For reflection-disabled or trimmed deployments, provide source-generated metadata at both boundaries. The state contract can also provide genuine generic input inference: ```csharp -using System.Text.Json; - -JsonElement stateJson = JsonSerializer.SerializeToElement( - ticket, - TicketJsonContext.Default.SupportTicket); -var analyzedFromJson = await client.GetResponseAsync( - stateJson, - metadataDefinition); +AIFunction generatedAnalyzeTicket = definition.AsAIFunction( + client, + functionOptions: new AIFunctionFactoryOptions + { + SerializerOptions = TicketJsonContext.Default.Options, + }, + stateTypeInfo: TicketJsonContext.Default.SupportTicket); ``` -Applications that only need the neutral response can keep the no-binding path: +`TicketJsonContext.Default.Options` covers both `SupportTicket` and `TicketAnalysis`; `stateTypeInfo` supplies the `TState` contract while `TResult` comes from `definition`. This is source-generation-compatible validation, not a Native AOT certification. Standard factory options remain standard: name and description overrides, schema options, parameter binding, result marshalling, additional properties, and result-schema exclusion are passed through. `DecisionOptions`, when supplied, are copied during construction and cloned again for each invocation. The clone is deliberately shallow: the options dictionary is copied, but nested values and a raw-options factory remain caller/provider-owned. + +When registering a function with DI, resolve the caller-owned decision client once while constructing the ordinary function. Do not resolve services from model-supplied arguments or from an ambient service provider during invocation: ```csharp -var evidenceOnly = await client.GetResponseAsync( - ticket, - TicketJsonContext.Default.SupportTicket, - metadataDefinition.Questions); +services.AddScoped(serviceProvider => +{ + IDecisionClient decisions = + serviceProvider.GetRequiredService(); + + return definition.AsAIFunction( + decisions, + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket", + SerializerOptions = TicketJsonContext.Default.Options, + }); +}); ``` -The JSON metadata setup is separate from the business call: +The registration's lifetime must be compatible with the registered client. The helper never disposes the client; the owner (for example, the DI scope) does. -```csharp -using System.Text.Json.Serialization; +### 2. Decision-driven chat routing -[JsonSourceGenerationOptions(UseStringEnumConverter = true)] -[JsonSerializable(typeof(SupportTicket))] -[JsonSerializable(typeof(TicketCategory))] -[JsonSerializable(typeof(TicketAnalysis))] -public partial class TicketJsonContext : JsonSerializerContext; -``` +Routing is a separate provider/client-selection problem. Construct one provider-neutral decision client and multiple configured, caller-owned `IChatClient` instances. Project meaningful bounded request state, use a typed `DecisionDefinition`, and compose with the existing `RoutingChatClient.Create`: + +```csharp +public enum RouteKind +{ + [Description("Fast: use for short, routine requests where lower latency is preferred and deep multi-step reasoning is not needed.")] + Fast, -`TicketCategory` is a normal caller-owned enum. `DecisionDefinition` is an explicit declaration of which result members participate; it is not an enum generator, ORM, arbitrary POCO/union inference engine, or LINQ query provider. The candidate IDs come from the configured JSON enum contract and are exact ordinal wire-correlation identities. They are stable simple strings reused through the question definition; no pipe/version syntax is required. If a domain needs dynamic IDs instead of a closed enum, use the lower-level `ChoiceDecisionQuestion` and explicit mapping APIs so stale or unknown IDs remain rejected. + [Description("Reasoning: use for multi-step analysis, comparisons, or requests that benefit from deeper deliberation.")] + Reasoning, +} -Attributes are an ergonomic fallback for instructions and enum candidate descriptions. A fluent instruction supplied to `Choice`, `BinaryProbability`, or `Score` wins over an attribute. The metadata reader checks an attributed property first, then an associated positional-record constructor parameter, then a type-level fallback. JSON enum serialization controls the candidate IDs, but JSON conversion's permissive/case-insensitive behavior does not replace exact decision correlation validation. +public sealed record RoutingDecision(RouteKind Route); +public sealed record RoutingMessage(string Role, string Text); +public sealed record RoutingState(IReadOnlyList Messages, bool HasTools); + +DecisionDefinition routeDefinition = + DecisionDefinition.Create( + RoutingJsonContext.Default.RoutingDecision, + builder => builder.Choice( + result => result.Route, + "Choose Fast for short, routine requests where latency is the priority. " + + "Choose Reasoning for multi-step analysis, comparisons, or requests that benefit from deeper deliberation.")); + +// Each provider-owned client must enforce its route identity on every request: +// Fast -> "fast-model"; Reasoning -> "reasoning-model". A provider's default +// model ID alone is not sufficient because RoutingChatClient forwards a clone +// of caller ChatOptions, including ModelId. +IReadOnlyDictionary clients = + new Dictionary + { + [RouteKind.Fast] = fastConfiguredClient, + [RouteKind.Reasoning] = reasoningConfiguredClient, + }; -`JsonTypeInfo` describes the serialized shape of state or a result. It may come from source generation or a configured reflection resolver; it is not handwritten JSON schema and it does not replace decision instructions, exact IDs, complete distributions, or stale-domain checks. The older `DecisionResultBinding` API still requires result metadata even though a delegate mapper may not use it; simplifying that requirement is an API review candidate, not an implicit behavior of this convenience layer. +RoutingChatClient router = RoutingChatClient.Create( + async (context, cancellationToken) => + { + const int maxMessages = 32; + const int maxMessageTextCharacters = 4_096; + const int maxTotalTextCharacters = 64_000; + List projectedMessages = []; + int totalTextCharacters = 0; + foreach (ChatMessage message in context.Messages) + { + if (projectedMessages.Count == maxMessages) + { + throw new InvalidOperationException( + $"Route selection accepts at most {maxMessages} messages; no history is truncated."); + } + + if (message.Role != ChatRole.System && + message.Role != ChatRole.User && + message.Role != ChatRole.Assistant && + message.Role != ChatRole.Tool) + { + throw new NotSupportedException( + $"Role '{message.Role}' is not supported by this route selector."); + } + + if (message.Contents.Any(static content => content is not TextContent)) + { + throw new NotSupportedException( + "This route selector accepts text content only; no content may be silently dropped."); + } + + if (message.Text.Length > maxMessageTextCharacters || + totalTextCharacters > maxTotalTextCharacters - message.Text.Length) + { + throw new InvalidOperationException( + "Route selection text budgets were exceeded; no text is truncated."); + } + + projectedMessages.Add(new(message.Role.Value, message.Text)); + totalTextCharacters += message.Text.Length; + } + + RoutingState state = new( + projectedMessages, + context.ChatOptions?.Tools is not null); + + DecisionResponse decision = + await decisionClient.GetResponseAsync( + state, + RoutingJsonContext.Default.RoutingState, + routeDefinition, + cancellationToken: cancellationToken); + + if (!clients.TryGetValue(decision.Result.Route, out IChatClient? selected)) + { + throw new InvalidOperationException("The decision selected an unconfigured route."); + } + + return selected; + }); +``` -The returned `Evidence` is the complete `DecisionResponse`, including every category probability and the binary observation. The mapped `TicketAnalysis` is therefore not a claim that a hidden threshold or calibration step established a business fact. Applications can retain or inspect the complete evidence for reporting, review, or later training. Feature projection is an optional follow-on: callers may define a versioned `DecisionFeatureSchema` of named native-`double` coordinates over complete observations when a downstream feature export is useful. That schema is a downstream column/coordinate contract, distinct from JSON metadata and not a prerequisite for typed classification. +Each configured client must enforce its route-specific model identity and options on every forwarded call (for example, by overwriting a caller-supplied `ChatOptions.ModelId` in the provider-owned client adapter or rejecting a conflicting value); merely setting a provider default is not enough. The router forwards the original messages and a cloned request-options object to exactly one selected client, including streaming calls. The caller owns the configured clients and remains responsible for disposing them; the router does not dispose borrowed clients. Invalid or wrong-domain decision responses, cancellation, and provider failures stop before chat forwarding. There is no retry, failover, conversation affinity, mid-stream switching, outcome learning, or automatic calibration. Routing selects before each ordinary or streaming call. If function middleware wraps the router, a later tool-loop round can re-enter the router; if a selected configured client owns its own loop, that client remains selected. This example does not add either behavior automatically. Decision probabilities are task observations, not calibrated task-success guarantees. -## .NET platform reuse / ergonomics under review +### Semantic ingestion proof -Layer 1 intentionally reuses existing .NET and MEAI primitives rather than adding `IQueryable`, an ORM, or a new source-generator dependency: +The MEDI proof remains consumer-owned and uses the existing document-reader -> chunker -> semantic decision chunk processor -> collecting/serializing writer flow. It preserves chunk content, document identity/context, unrelated metadata, bounded streaming, cancellation, and stable feature schema identity/version/provenance. It does not add `IngestionDocument.Metadata`, alter `ClassificationEnricher` or batching, fabricate chat responses, or add an upstream ML.NET/Arrow dependency. -- [`JsonSchemaExporter`](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/extract-schema) was introduced in .NET 9. The decision contracts use `JsonTypeInfo` and serializer options for explicit metadata; they do not add another schema exporter, and `AIJsonUtilities` provides the existing MEAI compatibility helpers where applicable. -- [System.Text.Json source generation](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation) is the AOT-friendly way to supply `JsonTypeInfo`. `JsonTypeInfo` itself is not synonymous with generated code; ordinary JIT reflection through a configured resolver remains a platform option. -- [`JsonStringEnumMemberNameAttribute`](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/customize-properties#custom-enum-member-names) and the generic [`JsonStringEnumConverter`](https://learn.microsoft.com/dotnet/api/system.text.json.serialization.jsonstringenumconverter-1) are useful for JSON enum representation. They do not replace decision ID validation: enum conversion is case-insensitive and permits integer values by default, while decision IDs are exact ordinal identities. -- [`AIFunctionFactory`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.aifunctionfactory) remains the existing MEAI primitive for inspecting an explicitly declared delegate, producing a tool schema, and marshalling inputs and outputs. Tool interoperability is a later layer, not a prerequisite for this decision foundation. -- [Structured output](https://learn.microsoft.com/dotnet/ai/quickstarts/structured-output) already supports enum/record-shaped chat results. If an application only needs a label or JSON result, `IChatClient.GetResponseAsync` may be the simpler choice; that path does not by itself establish a provider-reported probability distribution. -- [EF Core model conventions and explicit overrides](https://learn.microsoft.com/ef/core/modeling/) are useful precedent for bounded opt-in conventions, not a reason to add an EF dependency or `DbContext`. [EF Core query providers](https://learn.microsoft.com/ef/core/querying/) likewise do not justify an `IQueryable` decision API for explicit paid asynchronous model inference; ordinary LINQ projection over returned probabilities remains appropriate. +## Alternative Designs -Automated arbitrary semantic inference is out. This layer already uses bounded opt-in conventions for JSON enum naming and standard member descriptions; future conventions for known enum/member mappings remain reasonable only when explicit, source-generation/AOT-compatible, and unable to hide provider-facing identities or application-owned result mapping. +- A decision-specific `AIFunction` wrapper was removed: ordinary annotated methods plus `AIFunctionFactory.Create` already provide the safe schema, host closure, cancellation, and standard middleware composition. +- A reusable decision router was not added: typed `DecisionDefinition` plus `RoutingChatClient.Create` is enough for application-specific eligibility and client maps. +- A reusable MEDI processor was not added: the real pipeline proof does not establish a separately justified public ingestion API. -Provider evidence for the package-level contract is bounded and documented in the [upstream provider-validation report](https://github.com/luisquintanilla/typesafe-meai/blob/13adb58c7d7479bc8c73bf31960c08349b7c3185/docs/upstream-provider-validation.md): Julia/Laya provide CPU binary/choice/score proof, Qwen provides choice-only proof, and the dedicated `tev1:0.8b` Ollama/OllamaSharp path provides live binary/choice/score proof. Other adapters use native HTTP fixtures rather than live executions; this is not a claim of universal provider accuracy. +## Risks and limitations -This layer intentionally does not define tools, routing composition, MEDI processors, provider adapters, ML.NET or Arrow integrations, or automatic arbitrary POCO/union semantic inference. AIFunction/tool interoperability is a Layer 2 concern and is not implied by these abstractions. +These contracts are experimental (`MEAI001`). Hosts own client lifetime, safe output DTOs, evidence retention, route policy, and business actions. Provider adapters and external proof packages are not shipped or repinned here. No live provider/model execution, package-release availability, Native AOT certification, calibration guarantee, or universal provider accuracy is claimed. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index b422e38fe80..9e86807dd13 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -5707,6 +5707,10 @@ { "Member": "static Microsoft.Extensions.AI.DecisionDefinition Microsoft.Extensions.AI.DecisionDefinition.Create(System.Text.Json.Serialization.Metadata.JsonTypeInfo resultTypeInfo, System.Action.Builder> configure);", "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.DecisionDefinition.AsAIFunction(Microsoft.Extensions.AI.IDecisionClient client, Microsoft.Extensions.AI.AIFunctionFactoryOptions? functionOptions = null, Microsoft.Extensions.AI.DecisionOptions? decisionOptions = null, System.Text.Json.Serialization.Metadata.JsonTypeInfo? stateTypeInfo = null);", + "Stage": "Experimental" } ], "Properties": [ diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionRoutingProofTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionRoutingProofTests.cs new file mode 100644 index 00000000000..ce4ef5f5bef --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionRoutingProofTests.cs @@ -0,0 +1,355 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable SA1402 // Test models and their source-generated context are co-located. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public sealed class DecisionRoutingProofTests +{ + [Theory] + [InlineData(RouteKind.Fast, "fast-model")] + [InlineData(RouteKind.Reasoning, "reasoning-model")] + public async Task ConsumerOwnedTypedDecisionSelectorSelectsConfiguredClientAndPreservesRequest( + RouteKind expectedRoute, + string expectedModel) + { + ChatMessage[] messages = + [ + new(ChatRole.User, "Compare the retry behavior of these two designs."), + new(ChatRole.Assistant, "The first design retries immediately."), + ]; + ChatOptions options = new() { ModelId = "caller-model", Temperature = 0.2f }; + using CancellationTokenSource cancellation = new(); + using RecordingDecisionClient decisionClient = new(expectedRoute); + using ConfiguredChatClient fastClient = new("fast-model", "fast response"); + using ConfiguredChatClient reasoningClient = new("reasoning-model", "reasoning response"); + IReadOnlyDictionary clients = new Dictionary + { + [RouteKind.Fast] = fastClient, + [RouteKind.Reasoning] = reasoningClient, + }; + + using RoutingChatClient router = CreateRouter(decisionClient, clients); + ChatResponse response = await router.GetResponseAsync(messages, options, cancellation.Token); + + ConfiguredChatClient selected = expectedRoute == RouteKind.Fast ? fastClient : reasoningClient; + ConfiguredChatClient unused = expectedRoute == RouteKind.Fast ? reasoningClient : fastClient; + Assert.Equal($"{expectedRoute switch { RouteKind.Fast => "fast", _ => "reasoning" }} response", response.Text); + Assert.Same(messages, selected.ForwardedMessages); + Assert.NotSame(options, selected.ForwardedOptions); + Assert.Equal(expectedModel, selected.ForwardedOptions!.ModelId); + Assert.Equal(0.2f, selected.ForwardedOptions.Temperature); + Assert.Equal("caller-model", options.ModelId); + Assert.Equal(1, decisionClient.CallCount); + Assert.Equal(1, selected.CallCount); + Assert.Equal(0, unused.CallCount); + Assert.Equal(cancellation.Token, decisionClient.LastCancellationToken); + Assert.Equal(cancellation.Token, selected.LastCancellationToken); + + router.Dispose(); + Assert.Equal(0, selected.DisposeCount); + } + + [Fact] + public async Task ConsumerOwnedTypedDecisionSelectorForwardsStreamingResponse() + { + ChatMessage[] messages = [new(ChatRole.User, "Stream the reasoning route.")]; + ChatOptions options = new() { ModelId = "caller-model" }; + using RecordingDecisionClient decisionClient = new(RouteKind.Reasoning); + using ConfiguredChatClient fastClient = new("fast-model", "fast response"); + using ConfiguredChatClient reasoningClient = new("reasoning-model", "reasoning response"); + using RoutingChatClient router = CreateRouter( + decisionClient, + new Dictionary + { + [RouteKind.Fast] = fastClient, + [RouteKind.Reasoning] = reasoningClient, + }); + + List updates = []; + await foreach (ChatResponseUpdate update in router.GetStreamingResponseAsync(messages, options)) + { + updates.Add(update); + } + + Assert.Equal("reasoning response", Assert.Single(updates).Text); + Assert.Same(messages, reasoningClient.ForwardedMessages); + Assert.Equal("reasoning-model", reasoningClient.ForwardedOptions!.ModelId); + Assert.Equal(1, decisionClient.CallCount); + Assert.Equal(1, reasoningClient.StreamingCallCount); + Assert.Equal(0, fastClient.StreamingCallCount); + } + + [Fact] + public async Task ConsumerOwnedTypedDecisionSelectorRequiresConfiguredCandidate() + { + using RecordingDecisionClient decisionClient = new(RouteKind.Fast) { ReturnUnconfigured = true }; + using ConfiguredChatClient fastClient = new("fast-model", "fast response"); + using ConfiguredChatClient reasoningClient = new("reasoning-model", "reasoning response"); + using RoutingChatClient router = CreateRouter( + decisionClient, + new Dictionary + { + [RouteKind.Fast] = fastClient, + [RouteKind.Reasoning] = reasoningClient, + }); + + await Assert.ThrowsAsync( + () => router.GetResponseAsync([new(ChatRole.User, "select a route")])); + + Assert.Equal(1, decisionClient.CallCount); + Assert.Equal(0, fastClient.CallCount); + Assert.Equal(0, reasoningClient.CallCount); + } + + [Theory] + [InlineData(RouteResponseFailure.Provider)] + [InlineData(RouteResponseFailure.WrongQuestion)] + public async Task ConsumerOwnedTypedDecisionSelectorPropagatesDecisionFailureBeforeChat( + RouteResponseFailure failure) + { + using RecordingDecisionClient decisionClient = new(RouteKind.Fast) { Failure = failure }; + using ConfiguredChatClient fastClient = new("fast-model", "fast response"); + using ConfiguredChatClient reasoningClient = new("reasoning-model", "reasoning response"); + using RoutingChatClient router = CreateRouter( + decisionClient, + new Dictionary + { + [RouteKind.Fast] = fastClient, + [RouteKind.Reasoning] = reasoningClient, + }); + + Type exceptionType = failure == RouteResponseFailure.Provider + ? typeof(DecisionClientException) + : typeof(DecisionProtocolException); + Exception exception = await Assert.ThrowsAsync( + exceptionType, + () => router.GetResponseAsync([new(ChatRole.User, "select a route")])); + + Assert.NotNull(exception); + Assert.Equal(0, fastClient.CallCount); + Assert.Equal(0, reasoningClient.CallCount); + } + + [Fact] + public async Task ConsumerOwnedTypedDecisionSelectorPropagatesCancellationBeforeChat() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + using RecordingDecisionClient decisionClient = new(RouteKind.Fast); + using ConfiguredChatClient fastClient = new("fast-model", "fast response"); + using RoutingChatClient router = CreateRouter( + decisionClient, + new Dictionary + { + [RouteKind.Fast] = fastClient, + }); + + await Assert.ThrowsAnyAsync( + () => router.GetResponseAsync( + [new(ChatRole.User, "select a route")], + cancellationToken: cancellation.Token)); + + Assert.Equal(1, decisionClient.CallCount); + Assert.Equal(0, fastClient.CallCount); + } + + private static RoutingChatClient CreateRouter( + IDecisionClient decisionClient, + IReadOnlyDictionary clients) => + RoutingChatClient.Create(async (context, cancellationToken) => + { + string requestText = string.Join( + "\n", + context.Messages.Select(static message => message.Text)); + RoutingState state = new(requestText, context.ChatOptions?.Tools is not null); + DecisionResponse response = await decisionClient.GetResponseAsync( + state, + DecisionRoutingJsonContext.Default.RoutingState, + CreateDefinition(), + cancellationToken: cancellationToken); + + RouteKind selectedRoute = response.Result.Route; + if (!clients.TryGetValue(selectedRoute, out IChatClient? selected)) + { + throw new InvalidOperationException($"Decision selected unconfigured route '{selectedRoute}'."); + } + + return selected; + }); + + private static DecisionDefinition CreateDefinition() => + DecisionDefinition.Create( + DecisionRoutingJsonContext.Default.RoutingDecision, + definition => definition.Choice(result => result.Route)); + + private sealed class RecordingDecisionClient : IDecisionClient + { + private readonly RouteKind _selectedRoute; + + public RecordingDecisionClient(RouteKind selectedRoute) + { + _selectedRoute = selectedRoute; + } + + public int CallCount { get; private set; } + + public CancellationToken LastCancellationToken { get; private set; } + + public RouteResponseFailure Failure { get; init; } + + public bool ReturnUnconfigured { get; init; } + + public Task GetResponseAsync( + DecisionRequest request, + DecisionOptions? options = null, + CancellationToken cancellationToken = default) + { + CallCount++; + LastCancellationToken = cancellationToken; + + if (Failure == RouteResponseFailure.Provider) + { + return Task.FromException( + new DecisionClientException("route provider failed", isTransient: true)); + } + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + DecisionQuestion question = request.Questions[0]; + if (Failure == RouteResponseFailure.WrongQuestion) + { + question = new ChoiceDecisionQuestion( + "wrong-route-question", + question.Instructions, + ((ChoiceDecisionQuestion)question).Candidates); + } + + ChoiceDecisionQuestion choice = Assert.IsType(question); + string selectedId = ReturnUnconfigured + ? "unconfigured" + : _selectedRoute switch + { + RouteKind.Fast => choice.Candidates[0].Id, + RouteKind.Reasoning => choice.Candidates[1].Id, + _ => throw new InvalidOperationException(), + }; + DecisionRequest responseRequest = new(request.State, [question]); + return Task.FromResult( + new DecisionResponse( + responseRequest, + [ + new ChoiceDecisionAnswer( + question.Id, + selectedId, + [ + new(choice.Candidates[0].Id, selectedId == choice.Candidates[0].Id ? 0.9 : 0.1), + new(choice.Candidates[1].Id, selectedId == choice.Candidates[1].Id ? 0.9 : 0.1), + ]), + ])); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } + + private sealed class ConfiguredChatClient : IChatClient + { + private readonly string _configuredModel; + private readonly string _responseText; + + public ConfiguredChatClient(string configuredModel, string responseText) + { + _configuredModel = configuredModel; + _responseText = responseText; + } + + public int CallCount { get; private set; } + + public int StreamingCallCount { get; private set; } + + public int DisposeCount { get; private set; } + + public IEnumerable? ForwardedMessages { get; private set; } + + public ChatOptions? ForwardedOptions { get; private set; } + + public CancellationToken LastCancellationToken { get; private set; } + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + CallCount++; + ForwardedMessages = messages; + ChatOptions forwardedOptions = options ?? new ChatOptions(); + ForwardedOptions = forwardedOptions; + LastCancellationToken = cancellationToken; + forwardedOptions.ModelId = _configuredModel; + + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, _responseText))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + StreamingCallCount++; + ForwardedMessages = messages; + ChatOptions forwardedOptions = options ?? new ChatOptions(); + ForwardedOptions = forwardedOptions; + LastCancellationToken = cancellationToken; + forwardedOptions.ModelId = _configuredModel; + + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return new ChatResponseUpdate(ChatRole.Assistant, _responseText); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() => DisposeCount++; + } + + public enum RouteResponseFailure + { + None, + Provider, + WrongQuestion, + } + + public enum RouteKind + { + Fast, + Reasoning, + } + + internal sealed record RoutingDecision(RouteKind Route); + + internal sealed record RoutingState(string RequestText, bool HasTools); +} + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(DecisionRoutingProofTests.RoutingDecision))] +[JsonSerializable(typeof(DecisionRoutingProofTests.RoutingState))] +internal sealed partial class DecisionRoutingJsonContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs new file mode 100644 index 00000000000..4886c0879d9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTypedFunctionTests.cs @@ -0,0 +1,636 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable SA1402 // Test models and their source-generated context are co-located. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public sealed class DecisionTypedFunctionTests +{ + [Fact] + public async Task AIFunctionFactory_UsesAnnotatedMethodAndTypedDecisionDefinition() + { + using SupportTicketDecisionClient client = new(); + DecisionDefinition definition = CreateDefinition(); + SupportTicketFunction host = new(client, definition); + + AIFunction function = AIFunctionFactory.Create( + host.AnalyzeAsync, + new AIFunctionFactoryOptions + { + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }); + + Assert.Equal("analyze_support_ticket", function.Name); + Assert.Equal("Annotate one support ticket.", function.Description); + JsonElement schema = function.JsonSchema; + JsonElement properties = schema.GetProperty("properties"); + Assert.Equal(["ticket"], properties.EnumerateObject().Select(static property => property.Name)); + Assert.Equal( + "The ticket to classify.", + properties.GetProperty("ticket").GetProperty("description").GetString()); + Assert.Contains("ticket", schema.GetProperty("required").EnumerateArray().Select(static value => value.GetString())); + Assert.DoesNotContain("client", properties.EnumerateObject().Select(static property => property.Name)); + Assert.DoesNotContain("definition", properties.EnumerateObject().Select(static property => property.Name)); + Assert.DoesNotContain("cancellationToken", properties.EnumerateObject().Select(static property => property.Name)); + + JsonElement output = Assert.IsType( + await function.InvokeAsync(new AIFunctionArguments + { + ["ticket"] = JsonSerializer.SerializeToElement( + new SupportTicket("The product is unavailable and I need help."), + DecisionTypedFunctionJsonContext.Default.SupportTicket), + })); + + Assert.Equal("Technical", output.GetProperty("category").GetString()); + Assert.Equal(0.25, output.GetProperty("refundRequestProbability").GetDouble()); + Assert.Equal(1, client.CallCount); + Assert.Equal( + "The product is unavailable and I need help.", + client.LastRequest!.State.GetProperty("message").GetString()); + } + + [Fact] + public async Task DecisionDefinition_AsAIFunction_UsesOneTypeSyntaxAndReturnsOnlyTheBusinessResult() + { + SupportTicketDecisionClient client = new(); + DecisionDefinition definition = CreateDefinition(); + AIFunction function = definition.AsAIFunction( + client, + new AIFunctionFactoryOptions + { + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }); + + Assert.Equal("evaluate_decision", function.Name); + Assert.Equal("Evaluate the declared decision for one application state.", function.Description); + JsonElement properties = function.JsonSchema.GetProperty("properties"); + Assert.Equal(["state"], properties.EnumerateObject().Select(static property => property.Name)); + Assert.Equal( + "The application state for the declared decision.", + properties.GetProperty("state").GetProperty("description").GetString()); + Assert.Contains("state", function.JsonSchema.GetProperty("required").EnumerateArray().Select(static value => value.GetString())); + + JsonElement output = Assert.IsType( + await function.InvokeAsync(CreateDecisionArguments())); + + Assert.Equal("Technical", output.GetProperty("category").GetString()); + Assert.Equal(0.25, output.GetProperty("refundRequestProbability").GetDouble()); + Assert.DoesNotContain("provenance", output.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("usage", output.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("provider-evidence", output.GetRawText(), StringComparison.Ordinal); + Assert.Equal(1, client.CallCount); + Assert.Equal( + "Please review this account issue.", + client.LastRequest!.State.GetProperty("message").GetString()); + Assert.Equal(0, client.DisposeCount); + + client.Dispose(); + } + + [Fact] + public async Task DecisionDefinition_AsAIFunction_PreservesFactoryOptionsAndClonesDecisionOptionsPerInvocation() + { + SupportTicketDecisionClient client = new() { MutateOptions = true }; + DecisionOptions decisionOptions = new() { ModelId = "configured-model" }; + AIFunction function = CreateDefinition().AsAIFunction( + client, + new AIFunctionFactoryOptions + { + Name = "custom_decision", + Description = "Custom decision description.", + ExcludeResultSchema = true, + MarshalResult = static (value, _, _) => + { + TicketAnalysis result = Assert.IsType(value); + Assert.Equal(TicketCategory.Technical, result.Category); + Assert.Equal(0.25, result.RefundRequestProbability); + return new ValueTask("custom-marshaled"); + }, + }, + decisionOptions); + decisionOptions.ModelId = "caller-mutated"; + + Assert.Equal("custom_decision", function.Name); + Assert.Equal("Custom decision description.", function.Description); + Assert.Null(function.ReturnJsonSchema); + + Assert.Equal("custom-marshaled", await function.InvokeAsync(CreateDecisionArguments())); + Assert.Equal("custom-marshaled", await function.InvokeAsync(CreateDecisionArguments("A second request."))); + + Assert.Equal(["configured-model", "configured-model"], client.SeenModelIds); + Assert.Equal(2, client.SeenOptions.Count); + Assert.NotSame(client.SeenOptions[0], client.SeenOptions[1]); + Assert.Equal("caller-mutated", decisionOptions.ModelId); + + client.Dispose(); + } + + [Fact] + public async Task DecisionDefinition_AsAIFunction_InfersStateFromSourceGeneratedMetadata() + { + SupportTicketDecisionClient client = new(); + DecisionDefinition definition = CreateResultOnlyDefinition(); + + AIFunction function = definition.AsAIFunction( + client, + functionOptions: new AIFunctionFactoryOptions + { + Name = "source_generated_decision", + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }, + stateTypeInfo: DecisionTypedFunctionJsonContext.Default.SupportTicket); + + JsonElement output = Assert.IsType( + await function.InvokeAsync(CreateRawDecisionArguments())); + + Assert.Equal("Technical", output.GetProperty("category").GetString()); + Assert.Equal(0.25, output.GetProperty("refundRequestProbability").GetDouble()); + Assert.Equal(1, client.CallCount); + Assert.Equal("Source-generated state.", client.LastRequest!.State.GetProperty("message").GetString()); + + client.Dispose(); + } + + [Fact] + public void DecisionDefinition_AsAIFunction_RequiresStateMetadataWhenDefinitionOptionsDoNotContainIt() + { + using SupportTicketDecisionClient client = new(); + + Assert.Throws( + () => CreateResultOnlyDefinition().AsAIFunction(client)); + } + + [Fact] + public async Task DecisionDefinition_AsAIFunction_PropagatesProviderAndCancellationFailures() + { + DecisionClientException providerFailure = new("provider failed", isTransient: true); + using SupportTicketDecisionClient failedClient = new() { Failure = providerFailure }; + AIFunction failedFunction = CreateDefinition().AsAIFunction(failedClient); + + DecisionClientException actualProviderFailure = await Assert.ThrowsAsync( + () => failedFunction.InvokeAsync(CreateDecisionArguments()).AsTask()); + Assert.Same(providerFailure, actualProviderFailure); + + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + using SupportTicketDecisionClient cancelledClient = new(); + AIFunction cancelledFunction = CreateDefinition().AsAIFunction(cancelledClient); + + await Assert.ThrowsAnyAsync( + () => cancelledFunction.InvokeAsync(CreateDecisionArguments(), cancellation.Token).AsTask()); + Assert.Equal(1, cancelledClient.CallCount); + } + + [Theory] + [InlineData(ResponseMismatch.State)] + [InlineData(ResponseMismatch.Question)] + [InlineData(ResponseMismatch.Candidate)] + public async Task DecisionDefinition_AsAIFunction_RejectsMismatchedResponsesBeforeReturningAResult(ResponseMismatch mismatch) + { + using SupportTicketDecisionClient client = new() { Mismatch = mismatch }; + AIFunction function = CreateDefinition().AsAIFunction(client); + + await Assert.ThrowsAsync( + () => function.InvokeAsync(CreateDecisionArguments()).AsTask()); + + Assert.Equal(1, client.CallCount); + } + + [Fact] + public void DecisionDefinition_AsAIFunction_RejectsNullClient() + { + Assert.Throws( + "client", + () => CreateDefinition().AsAIFunction(null!)); + } + + [Fact] + public async Task DecisionDefinition_AsAIFunction_CanBeRegisteredPerScopeWithOneClientResolution() + { + using SupportTicketDecisionClient client = new(); + ServiceCollection services = new(); + int clientFactoryCalls = 0; + services.AddScoped(_ => + { + clientFactoryCalls++; + return client; + }); + services.AddScoped(serviceProvider => + CreateDefinition().AsAIFunction( + serviceProvider.GetRequiredService(), + new AIFunctionFactoryOptions + { + Name = "scoped_decision", + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + })); + + using ServiceProvider provider = services.BuildServiceProvider(); + using IServiceScope scope = provider.CreateScope(); + AIFunction first = scope.ServiceProvider.GetRequiredService(); + AIFunction second = scope.ServiceProvider.GetRequiredService(); + + Assert.Same(first, second); + Assert.Equal(1, clientFactoryCalls); + + await first.InvokeAsync(CreateDecisionArguments()); + + Assert.Equal(1, client.CallCount); + Assert.Equal(0, client.DisposeCount); + } + + [Fact] + public async Task AIFunctionFactory_CanReturnBusinessResultAndRecordEvidencePerInvocation() + { + using SupportTicketDecisionClient client = new(); + DecisionDefinition definition = CreateDefinition(); + List evidence = []; + SupportTicketFunction host = new(client, definition, evidence.Add); + + AIFunction function = AIFunctionFactory.Create( + host.AnalyzeWithMetadataAsync, + new AIFunctionFactoryOptions + { + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }); + + JsonElement output = Assert.IsType( + await function.InvokeAsync(new AIFunctionArguments + { + ["ticket"] = JsonSerializer.SerializeToElement( + new SupportTicket("The product is unavailable and I need help."), + DecisionTypedFunctionJsonContext.Default.SupportTicket), + })); + + Assert.Equal("Technical", output.GetProperty("result").GetProperty("category").GetString()); + Assert.Equal(0.25, output.GetProperty("result").GetProperty("refundRequestProbability").GetDouble()); + Assert.Equal("test-provider", output.GetProperty("provenance").GetProperty("providerName").GetString()); + Assert.Equal("test-model", output.GetProperty("provenance").GetProperty("modelId").GetString()); + Assert.Equal(7, output.GetProperty("usage").GetProperty("inputTokenCount").GetInt32()); + Assert.Equal(2, output.GetProperty("usage").GetProperty("outputTokenCount").GetInt32()); + Assert.DoesNotContain("provider-evidence", output.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("retained", output.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("The product is unavailable", output.GetRawText(), StringComparison.Ordinal); + Assert.Single(evidence); + Assert.Equal("provider-evidence", evidence[0].RawRepresentation); + Assert.Equal("retained", evidence[0].AdditionalProperties!["evidence"]); + } + + [Fact] + public void AIFunctionFactory_OptionsOverrideAnnotatedMetadata() + { + using SupportTicketDecisionClient client = new(); + SupportTicketFunction host = new(client, CreateDefinition()); + + AIFunction function = AIFunctionFactory.Create( + host.AnalyzeAsync, + new AIFunctionFactoryOptions + { + Name = "override_name", + Description = "Override description.", + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }); + + Assert.Equal("override_name", function.Name); + Assert.Equal("Override description.", function.Description); + } + + [Fact] + public async Task AIFunctionFactory_PropagatesCancellationProviderAndHostFailures() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + using SupportTicketDecisionClient cancelledClient = new(); + SupportTicketFunction cancelledHost = new(cancelledClient, CreateDefinition()); + AIFunction cancelledFunction = CreateFunction(cancelledHost.AnalyzeAsync); + + await Assert.ThrowsAnyAsync( + () => cancelledFunction.InvokeAsync( + CreateArguments(), + cancellation.Token).AsTask()); + Assert.Equal(1, cancelledClient.CallCount); + + DecisionClientException providerFailure = new("provider failed", isTransient: true); + using SupportTicketDecisionClient failedClient = new() { Failure = providerFailure }; + AIFunction failedFunction = CreateFunction(new SupportTicketFunction(failedClient, CreateDefinition()).AnalyzeAsync); + + DecisionClientException actualProviderFailure = await Assert.ThrowsAsync( + () => failedFunction.InvokeAsync(CreateArguments()).AsTask()); + Assert.Same(providerFailure, actualProviderFailure); + + InvalidOperationException hostFailure = new("host evidence failed"); + using SupportTicketDecisionClient hostClient = new(); + SupportTicketFunction failingHost = new(hostClient, CreateDefinition(), _ => throw hostFailure); + AIFunction hostFunction = CreateFunction(failingHost.AnalyzeWithMetadataAsync); + + InvalidOperationException actualHostFailure = await Assert.ThrowsAsync( + () => hostFunction.InvokeAsync(CreateArguments()).AsTask()); + Assert.Same(hostFailure, actualHostFailure); + } + + [Theory] + [InlineData(ResponseMismatch.State)] + [InlineData(ResponseMismatch.Question)] + [InlineData(ResponseMismatch.Candidate)] + public async Task AIFunctionFactory_RejectsResponseCorrelationBeforeResultMapping(ResponseMismatch mismatch) + { + int mapperCalls = 0; + using SupportTicketDecisionClient client = new() { Mismatch = mismatch }; + DecisionDefinition definition = CreateDefinition(); + SupportTicketFunction host = new(client, definition, _ => mapperCalls++); + AIFunction function = CreateFunction(host.AnalyzeWithMetadataAsync); + + await Assert.ThrowsAsync( + () => function.InvokeAsync(CreateArguments()).AsTask()); + + Assert.Equal(1, client.CallCount); + Assert.Equal(0, mapperCalls); + } + + [Fact] + public async Task AIFunctionFactory_RejectsInvalidSelectedCandidateDuringTypedBinding() + { + using SupportTicketDecisionClient client = new() { InvalidSelectedCandidate = true }; + AIFunction function = CreateFunction(new SupportTicketFunction(client, CreateDefinition()).AnalyzeAsync); + + await Assert.ThrowsAsync( + () => function.InvokeAsync(CreateArguments()).AsTask()); + + Assert.Equal(1, client.CallCount); + } + + private static AIFunction CreateFunction(Delegate method) => + AIFunctionFactory.Create( + method, + new AIFunctionFactoryOptions + { + Name = "analyze_support_ticket", + SerializerOptions = DecisionTypedFunctionJsonContext.Default.Options, + }); + + private static AIFunctionArguments CreateArguments() => + new() + { + ["ticket"] = JsonSerializer.SerializeToElement( + new SupportTicket("Please review this account issue."), + DecisionTypedFunctionJsonContext.Default.SupportTicket), + }; + + private static AIFunctionArguments CreateDecisionArguments(string message = "Please review this account issue.") => + new() + { + ["state"] = JsonSerializer.SerializeToElement( + new SupportTicket(message), + DecisionTypedFunctionJsonContext.Default.SupportTicket), + }; + + private static AIFunctionArguments CreateRawDecisionArguments() + { + using JsonDocument document = JsonDocument.Parse("{\"message\":\"Source-generated state.\"}"); + return new() + { + ["state"] = document.RootElement.Clone(), + }; + } + + private static DecisionDefinition CreateDefinition() => + DecisionDefinition.Create( + DecisionTypedFunctionJsonContext.Default.TicketAnalysis, + definition => + { + definition.Choice(result => result.Category); + definition.BinaryProbability(result => result.RefundRequestProbability); + }); + + private static DecisionDefinition CreateResultOnlyDefinition() => + DecisionDefinition.Create( + DecisionResultOnlyJsonContext.Default.TicketAnalysis, + definition => + { + definition.Choice(result => result.Category); + definition.BinaryProbability(result => result.RefundRequestProbability); + }); + + private sealed class SupportTicketFunction + { + private readonly IDecisionClient _client; + private readonly DecisionDefinition _definition; + private readonly Action? _recordEvidence; + + public SupportTicketFunction( + IDecisionClient client, + DecisionDefinition definition, + Action? recordEvidence = null) + { + _client = client; + _definition = definition; + _recordEvidence = recordEvidence; + } + + [DisplayName("analyze_support_ticket")] + [Description("Annotate one support ticket.")] + public async Task AnalyzeAsync( + [Description("The ticket to classify.")] SupportTicket ticket, + CancellationToken cancellationToken) + { + DecisionResponse response = await _client.GetResponseAsync( + ticket, + DecisionTypedFunctionJsonContext.Default.SupportTicket, + _definition, + cancellationToken: cancellationToken); + _recordEvidence?.Invoke(response.Evidence); + return response.Result; + } + + [DisplayName("analyze_support_ticket_with_metadata")] + [Description("Annotate one support ticket and return selected decision metadata.")] + public async Task AnalyzeWithMetadataAsync( + [Description("The ticket to classify.")] SupportTicket ticket, + CancellationToken cancellationToken) + { + DecisionResponse response = await _client.GetResponseAsync( + ticket, + DecisionTypedFunctionJsonContext.Default.SupportTicket, + _definition, + cancellationToken: cancellationToken); + _recordEvidence?.Invoke(response.Evidence); + return new( + response.Result, + response.Evidence.Provenance, + response.Evidence.Usage); + } + } + + private sealed class SupportTicketDecisionClient : IDecisionClient + { + public int CallCount { get; private set; } + + public DecisionClientException? Failure { get; init; } + + public ResponseMismatch Mismatch { get; init; } + + public bool InvalidSelectedCandidate { get; init; } + + public DecisionRequest? LastRequest { get; private set; } + + public bool MutateOptions { get; init; } + + public List SeenOptions { get; } = []; + + public List SeenModelIds { get; } = []; + + public int DisposeCount { get; private set; } + + public Task GetResponseAsync( + DecisionRequest request, + DecisionOptions? options = null, + CancellationToken cancellationToken = default) + { + CallCount++; + LastRequest = request; + if (options is not null) + { + SeenOptions.Add(options); + SeenModelIds.Add(options.ModelId); + if (MutateOptions) + { + options.ModelId = "provider-mutated"; + } + } + + if (Failure is not null) + { + return Task.FromException(Failure); + } + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + DecisionQuestion[] questions = request.Questions.ToArray(); + JsonElement state = request.State; + if (Mismatch == ResponseMismatch.State) + { + state = JsonSerializer.SerializeToElement( + new SupportTicket("different state"), + DecisionTypedFunctionJsonContext.Default.SupportTicket); + } + + if (Mismatch == ResponseMismatch.Question) + { + ChoiceDecisionQuestion choice = Assert.IsType(questions[0]); + questions[0] = new ChoiceDecisionQuestion("different-question", choice.Instructions, choice.Candidates); + } + else if (Mismatch == ResponseMismatch.Candidate) + { + ChoiceDecisionQuestion choice = Assert.IsType(questions[0]); + questions[0] = new ChoiceDecisionQuestion( + choice.Id, + choice.Instructions, + [new DecisionCandidate("unrelated", "Unrelated candidate.")]); + } + + DecisionRequest responseRequest = new(state, questions); + ChoiceDecisionQuestion responseChoice = Assert.IsType(responseRequest.Questions[0]); + BinaryDecisionQuestion responseBinary = Assert.IsType(responseRequest.Questions[1]); + string selectedCandidateId = InvalidSelectedCandidate + ? "unknown" + : responseChoice.Candidates[Math.Min(1, responseChoice.Candidates.Count - 1)].Id; + DecisionProbability[] probabilities = responseChoice.Candidates + .Select((candidate, index) => new DecisionProbability(candidate.Id, GetProbability( + responseChoice.Candidates.Count, + index))) + .ToArray(); + + return Task.FromResult( + new DecisionResponse( + responseRequest, + [ + new ChoiceDecisionAnswer(responseChoice.Id, selectedCandidateId, probabilities), + new BinaryDecisionAnswer(responseBinary.Id, 0.25), + ], + new DecisionProvenance(providerName: "test-provider", modelId: "test-model"), + new UsageDetails { InputTokenCount = 7, OutputTokenCount = 2 }, + rawRepresentation: "provider-evidence", + additionalProperties: new Dictionary { ["evidence"] = "retained" })); + } + + private static double GetProbability(int candidateCount, int index) => + candidateCount == 1 + ? 1 + : index switch + { + 0 => 0.2, + 1 => 0.5, + _ => 0.3, + }; + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + DisposeCount = 1; + } + } + + public enum ResponseMismatch + { + None, + State, + Question, + Candidate, + } + + internal sealed record SupportTicket(string Message); + + internal enum TicketCategory + { + [Description("Payment, invoice, or charge concerns.")] + Billing, + + [Description("Product defects, errors, outages, or troubleshooting.")] + Technical, + + [Description("Sign-in, profile, or account-access concerns.")] + Account, + } + + internal sealed record TicketAnalysis( + [Description("Classify the customer's main concern.")] TicketCategory Category, + [Description("Is the customer requesting a refund or payment reversal?")] double RefundRequestProbability); + + internal sealed record MetadataOnlyToolResult( + TicketAnalysis Result, + DecisionProvenance? Provenance, + UsageDetails? Usage); +} + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(DecisionTypedFunctionTests.SupportTicket))] +[JsonSerializable(typeof(DecisionTypedFunctionTests.TicketCategory))] +[JsonSerializable(typeof(DecisionTypedFunctionTests.TicketAnalysis))] +[JsonSerializable(typeof(DecisionTypedFunctionTests.MetadataOnlyToolResult))] +[JsonSerializable(typeof(DecisionProvenance))] +[JsonSerializable(typeof(UsageDetails))] +internal sealed partial class DecisionTypedFunctionJsonContext : JsonSerializerContext; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(DecisionTypedFunctionTests.TicketCategory))] +[JsonSerializable(typeof(DecisionTypedFunctionTests.TicketAnalysis))] +internal sealed partial class DecisionResultOnlyJsonContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj index f2b8c40018a..896c246d179 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/DecisionSemanticPipelineTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/DecisionSemanticPipelineTests.cs new file mode 100644 index 00000000000..133f2f5216a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/DecisionSemanticPipelineTests.cs @@ -0,0 +1,296 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable MEAI001 // Decision APIs are intentionally exercised by this proof. +#pragma warning disable SA1402 // Test models and their source-generated context are co-located. +#pragma warning disable SA1118 // Reader input is kept readable in the test. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.ML.Tokenizers; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Tests; + +public sealed class DecisionSemanticPipelineTests +{ + private static readonly ChoiceDecisionQuestion _question = new( + "topic", + "Choose the semantic topic of this chunk.", + [ + new DecisionCandidate("technical", "Technical content"), + new DecisionCandidate("other", "Other content"), + ]); + + [Fact] + public async Task RealReaderChunkerDecisionProcessorAndWriterPreserveAndSerialize() + { + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".md"); + File.WriteAllText( + path, + """ + # First section + + Technical content stays in the first chunk. + + ## Second section + + More content stays in the second chunk. + """); + + try + { + using RecordingDecisionClient decisionClient = new(); + CollectingWriter writer = new(); + using IngestionPipeline pipeline = new( + new MarkdownReader(), + CreateChunker(), + writer); + pipeline.ChunkProcessors.Add(new MetadataSeedProcessor()); + pipeline.ChunkProcessors.Add( + new SemanticDecisionChunkProcessor( + decisionClient, + DecisionPipelineJsonContext.Default.SemanticDecisionState, + new DecisionFeatureSchema( + "semantic-topic-v3", + 3, + [new("topic.technical", "topic", DecisionFeatureValueKind.ChoiceProbability, "technical")]))); + + IngestionResult result = await pipeline.ProcessAsync([new FileInfo(path)]).SingleAsync(); + + Assert.True(result.Succeeded); + Assert.Equal(2, decisionClient.CallCount); + Assert.Equal(2, writer.Serialized.Count); + + List records = writer.Serialized + .Select(json => JsonSerializer.Deserialize(json, DecisionPipelineJsonContext.Default.SerializedChunk)!) + .ToList(); + + Assert.All(records, record => + { + Assert.Equal(path, record.DocumentId); + Assert.Equal("preserve-me", record.UnrelatedMetadata); + Assert.Equal("semantic-topic-v3", record.FeatureSchemaId); + Assert.Equal(3, record.FeatureSchemaVersion); + Assert.Equal("test-provider", record.ProviderName); + Assert.Equal(0.12345678901234567, record.FeatureValues["topic.technical"]); + Assert.NotEmpty(record.Content); + }); + Assert.Contains(records, record => record.Context is not null); + Assert.Contains(records, record => record.Content.Contains("Technical content", StringComparison.Ordinal)); + Assert.Contains(records, record => record.Content.Contains("More content", StringComparison.Ordinal)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task DecisionProcessorReportsProviderFailureThroughIngestionResult() + { + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".md"); + File.WriteAllText(path, "# Section\n\nContent."); + + try + { + using RecordingDecisionClient decisionClient = new() + { + Failure = new DecisionClientException("decision provider failed", isTransient: false), + }; + CollectingWriter writer = new(); + using IngestionPipeline pipeline = new( + new MarkdownReader(), + CreateChunker(), + writer); + pipeline.ChunkProcessors.Add(new SemanticDecisionChunkProcessor( + decisionClient, + DecisionPipelineJsonContext.Default.SemanticDecisionState, + new DecisionFeatureSchema( + "semantic-topic-v3", + 3, + [new("topic.technical", "topic", DecisionFeatureValueKind.ChoiceProbability, "technical")]))); + + IngestionResult result = await pipeline.ProcessAsync([new FileInfo(path)]).SingleAsync(); + + Assert.False(result.Succeeded); + Assert.Same(decisionClient.Failure, result.Exception); + Assert.Empty(writer.Serialized); + } + finally + { + File.Delete(path); + } + } + + private sealed class MetadataSeedProcessor : IngestionChunkProcessor + { + public override async IAsyncEnumerable> ProcessAsync( + IAsyncEnumerable> chunks, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (IngestionChunk chunk in chunks.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + chunk.Metadata["unrelated"] = "preserve-me"; + yield return chunk; + } + + } + } + + private static IngestionChunker CreateChunker() => + new HeaderChunker(new(TiktokenTokenizer.CreateForModel("gpt-4"))); + + private sealed class SemanticDecisionChunkProcessor : IngestionChunkProcessor + { + private readonly IDecisionClient _client; + private readonly JsonTypeInfo _stateTypeInfo; + private readonly DecisionFeatureSchema _featureSchema; + + public SemanticDecisionChunkProcessor( + IDecisionClient client, + JsonTypeInfo stateTypeInfo, + DecisionFeatureSchema featureSchema) + { + _client = client; + _stateTypeInfo = stateTypeInfo; + _featureSchema = featureSchema; + } + + public override async IAsyncEnumerable> ProcessAsync( + IAsyncEnumerable> chunks, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (IngestionChunk chunk in chunks.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + DecisionResponse response = await _client.GetResponseAsync( + new SemanticDecisionState(chunk.Content.Length, chunk.Context is not null), + _stateTypeInfo, + [_question], + cancellationToken: cancellationToken).ConfigureAwait(false); + DecisionFeatureVector features = DecisionFeatureProjection.Project(response, _featureSchema); + + chunk.Metadata["decision.features"] = features; + yield return chunk; + } + } + } + + private sealed class CollectingWriter : IngestionChunkWriter + { + public List Serialized { get; } = []; + + public override async Task WriteAsync( + IAsyncEnumerable> chunks, + CancellationToken cancellationToken = default) + { + await foreach (IngestionChunk chunk in chunks.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + if (chunk.Metadata["decision.features"] is not DecisionFeatureVector features) + { + throw new InvalidOperationException("The decision feature projection was not attached."); + } + + Serialized.Add( + JsonSerializer.Serialize( + new SerializedChunk + { + Content = chunk.Content, + DocumentId = chunk.Document.Identifier, + Context = chunk.Context, + UnrelatedMetadata = (string)chunk.Metadata["unrelated"], + FeatureSchemaId = features.Schema.Id, + FeatureSchemaVersion = features.Schema.Version, + FeatureValues = features.Values.ToDictionary(static value => value.Name, static value => value.Value), + ProviderName = features.Provenance?.ProviderName, + }, + DecisionPipelineJsonContext.Default.SerializedChunk)); + } + } + } + + private sealed class RecordingDecisionClient : IDecisionClient + { + public int CallCount { get; private set; } + + public Exception? Failure { get; set; } + + public Task GetResponseAsync( + DecisionRequest request, + DecisionOptions? options = null, + CancellationToken cancellationToken = default) + { + CallCount++; + + if (Failure is not null) + { + return Task.FromException(Failure); + } + + return Task.FromResult( + new DecisionResponse( + request, + [ + new ChoiceDecisionAnswer( + "topic", + "technical", + [ + new("technical", 0.12345678901234567), + new("other", 0.8765432109876543), + ]), + ], + new DecisionProvenance(providerName: "test-provider"))); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } + + internal sealed class SemanticDecisionState + { + public SemanticDecisionState(int contentLength, bool hasContext) + { + ContentLength = contentLength; + HasContext = hasContext; + } + + public int ContentLength { get; } + + public bool HasContext { get; } + } + + internal sealed class SerializedChunk + { + public string Content { get; set; } = string.Empty; + + public string DocumentId { get; set; } = string.Empty; + + public string? Context { get; set; } + + public string UnrelatedMetadata { get; set; } = string.Empty; + + public string FeatureSchemaId { get; set; } = string.Empty; + + public int FeatureSchemaVersion { get; set; } + + public Dictionary FeatureValues { get; set; } = []; + + public string? ProviderName { get; set; } + } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(DecisionSemanticPipelineTests.SemanticDecisionState))] +[JsonSerializable(typeof(DecisionSemanticPipelineTests.SerializedChunk))] +internal sealed partial class DecisionPipelineJsonContext : JsonSerializerContext;