Skip to content
Open

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// 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 // File may only contain a single type — binding proof types are co-located as one API surface.
#pragma warning disable SA1649 // File name should match first type name — binding proof types are co-located as one API surface.
#pragma warning disable SA1204 // Static extension type is intentionally grouped with the binding types.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.AI;

/// <summary>Provides an explicit mapping from a choice candidate ID to an enum value.</summary>
/// <typeparam name="TEnum">The application-owned enum type.</typeparam>
[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
public sealed class DecisionEnumDefinition<TEnum>
where TEnum : struct, Enum
{
private readonly ReadOnlyDictionary<string, TEnum> _values;

/// <summary>Initializes a new instance of the <see cref="DecisionEnumDefinition{TEnum}"/> class.</summary>
public DecisionEnumDefinition(IEnumerable<DecisionEnumValue<TEnum>> values)
{
_ = Throw.IfNull(values);

DecisionEnumValue<TEnum>[] copy = values.ToArray();
if (copy.Length == 0)
{
Throw.ArgumentException(nameof(values), "An enum definition requires at least one value.");
}

Dictionary<string, TEnum> byId = new(StringComparer.Ordinal);
HashSet<TEnum> enumValues = new();
DecisionCandidate[] candidates = new DecisionCandidate[copy.Length];

for (int i = 0; i < copy.Length; i++)
{
DecisionEnumValue<TEnum> value = copy[i];
if (!enumValues.Add(value.Value))
{
Throw.ArgumentException(nameof(values), "Enum aliases or duplicate enum values are not allowed.");
}

if (byId.ContainsKey(value.Id))
{
Throw.ArgumentException(
nameof(values),
"Enum candidate IDs must be unique using ordinal comparison.");
}

byId.Add(value.Id, value.Value);
candidates[i] = new DecisionCandidate(value.Id, value.Description);
}

_values = new ReadOnlyDictionary<string, TEnum>(byId);
Candidates = Array.AsReadOnly(candidates);
}

/// <summary>Gets the ordered candidate snapshot for a choice question.</summary>
public IReadOnlyList<DecisionCandidate> Candidates { get; }

/// <summary>Creates a choice question using this exact enum mapping.</summary>
/// <returns>The choice question with the exact mapped candidate IDs.</returns>
public ChoiceDecisionQuestion CreateQuestion(string id, string? instructions) =>
new(id, instructions, Candidates);

/// <summary>Maps an exact selected candidate ID to its enum value.</summary>
/// <returns>The mapped enum value.</returns>
public TEnum Parse(string candidateId)
{
_ = Throw.IfNullOrWhitespace(candidateId);
if (_values.TryGetValue(candidateId, out TEnum value))
{
return value;
}

throw new DecisionProtocolException($"Unknown candidate ID '{candidateId}'.");
}
}

/// <summary>Describes one enum mapping entry.</summary>
/// <typeparam name="TEnum">The application-owned enum type.</typeparam>
[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
public sealed class DecisionEnumValue<TEnum>
where TEnum : struct, Enum
{
/// <summary>Initializes a new instance of the <see cref="DecisionEnumValue{TEnum}"/> class.</summary>
public DecisionEnumValue(TEnum value, string id, string description)
{
Value = value;
Id = Throw.IfNullOrWhitespace(id);
Description = Throw.IfNullOrWhitespace(description);
}

/// <summary>Gets the application enum value.</summary>
public TEnum Value { get; }

/// <summary>Gets the exact candidate ID.</summary>
public string Id { get; }

/// <summary>Gets the candidate description.</summary>
public string Description { get; }
}

/// <summary>Provides typed access to a complete decision response.</summary>
[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
public sealed class DecisionAnswerSet
{
internal DecisionAnswerSet(DecisionResponse response)
{
Response = response;
}

/// <summary>Gets the complete unmodified response, including probability evidence.</summary>
public DecisionResponse Response { get; }

/// <summary>Gets a binary answer by exact question ID.</summary>
/// <returns>The complete binary answer.</returns>
public BinaryDecisionAnswer GetBinary(string questionId) =>
Response.GetAnswer(questionId) as BinaryDecisionAnswer ??
throw new DecisionProtocolException($"Question '{questionId}' is not binary.");

/// <summary>Gets a choice answer by exact question ID.</summary>
/// <returns>The complete choice answer.</returns>
public ChoiceDecisionAnswer GetChoice(string questionId) =>
Response.GetAnswer(questionId) as ChoiceDecisionAnswer ??
throw new DecisionProtocolException($"Question '{questionId}' is not a choice.");

/// <summary>Gets a choice answer and maps its exact selected ID to an enum.</summary>
/// <typeparam name="TEnum">The application-owned enum type.</typeparam>
/// <returns>The mapped enum value.</returns>
public TEnum GetChoice<TEnum>(string questionId, DecisionEnumDefinition<TEnum> definition)
where TEnum : struct, Enum
{
_ = Throw.IfNull(definition);
return definition.Parse(GetChoice(questionId).SelectedCandidateId);
}

/// <summary>Gets a score answer by exact question ID.</summary>
/// <returns>The complete score answer.</returns>
public ScoreDecisionAnswer GetScore(string questionId) =>
Response.GetAnswer(questionId) as ScoreDecisionAnswer ??
throw new DecisionProtocolException($"Question '{questionId}' is not a score.");
}

/// <summary>Maps one complete response to an application-owned result without inference or reflection.</summary>
/// <typeparam name="TResult">The application-owned result type.</typeparam>
[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
public sealed class DecisionResultBinding<TResult>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README calls DecisionResultBinding the “older” API, but both it and the typed-definition API are introduced by this PR. Is the delegate-binding surface retained from an earlier prototype, or does it serve a distinct scenario that should remain public?

{
private readonly Func<DecisionAnswerSet, TResult> _map;

/// <summary>Initializes a new instance of the <see cref="DecisionResultBinding{TResult}"/> class.</summary>
/// <param name="resultTypeInfo">The source-generated JSON contract for the application result.</param>
/// <param name="map">The application-owned mapping delegate.</param>
public DecisionResultBinding(
JsonTypeInfo<TResult> resultTypeInfo,
Func<DecisionAnswerSet, TResult> map)
{
ResultTypeInfo = Throw.IfNull(resultTypeInfo);
_map = Throw.IfNull(map);
}

/// <summary>Gets the explicit source-generated result contract.</summary>
public JsonTypeInfo<TResult> ResultTypeInfo { get; }

internal TResult Map(DecisionResponse response) => _map(new DecisionAnswerSet(response));
}

/// <summary>Provides response binding extensions.</summary>
[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
public static class DecisionBindingExtensions
{
/// <summary>Maps a complete response locally using the supplied explicit binding.</summary>
/// <typeparam name="TResult">The application-owned result type.</typeparam>
/// <returns>The application-owned result.</returns>
public static TResult Bind<TResult>(
this DecisionResponse response,
DecisionResultBinding<TResult> binding)
{
_ = Throw.IfNull(response);
_ = Throw.IfNull(binding);
return binding.Map(response);
}
}
Loading
Loading