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
30 changes: 30 additions & 0 deletions AgentExperience.NET.sln
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.Storage.Pos
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.Storage.Postgres.Tests", "tests\AgentExperience.Storage.Postgres.Tests\AgentExperience.Storage.Postgres.Tests.csproj", "{86C76642-C4EB-49A8-9E4C-A885644EE49F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.Storage.Postgres.Vectors", "src\AgentExperience.Storage.Postgres.Vectors\AgentExperience.Storage.Postgres.Vectors.csproj", "{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.Storage.Postgres.Vectors.Tests", "tests\AgentExperience.Storage.Postgres.Vectors.Tests\AgentExperience.Storage.Postgres.Vectors.Tests.csproj", "{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -143,6 +147,30 @@ Global
{86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|x64.Build.0 = Release|Any CPU
{86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|x86.ActiveCfg = Release|Any CPU
{86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|x86.Build.0 = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|x64.ActiveCfg = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|x64.Build.0 = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|x86.ActiveCfg = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|x86.Build.0 = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|Any CPU.Build.0 = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|x64.ActiveCfg = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|x64.Build.0 = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|x86.ActiveCfg = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|x86.Build.0 = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|x64.ActiveCfg = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|x64.Build.0 = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|x86.ActiveCfg = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|x86.Build.0 = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|Any CPU.Build.0 = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|x64.ActiveCfg = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|x64.Build.0 = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|x86.ActiveCfg = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -157,5 +185,7 @@ Global
{9B771769-47C4-4EA1-8808-3E4728899A04} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{86C76642-C4EB-49A8-9E4C-A885644EE49F} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
518 changes: 507 additions & 11 deletions README.md

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions src/AgentExperience.Abstractions/ExperienceCandidateSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
namespace AgentExperience.Abstractions;

/// <summary>
/// Port for finding Experience Records that could apply to a task, matched on task text. It is a
/// read-only search seam kept deliberately separate from <see cref="IExperienceRecordStore"/>: the
/// store persists and reads canonical records by identity or scope, while this port answers "which
/// stored records look relevant to this text?" and nothing else.
/// </summary>
/// <remarks>
/// <para>
/// The same trust boundary applies as to <see cref="IExperienceRecordStore"/>: every call takes a
/// host-established <see cref="AuthorizationContext"/>, a request scope outside it is
/// <see cref="ExperienceStoreOutcome.Denied"/> before any storage access, and scope matching is exact
/// (ordinal, case-sensitive, <see langword="null"/> matches only <see langword="null"/>). Expected
/// conditions return typed results; infrastructure failures throw
/// <see cref="ExperienceStoreException"/>; caller cancellation surfaces as an unwrapped
/// <see cref="OperationCanceledException"/>.
/// </para>
/// <para>
/// An implementation decides <em>nothing</em> about eligibility beyond what the query asks for: it
/// applies the scope, the requested statuses, and the minimum confidence, matches the text, and
/// returns each match with a normalized relevance. Which statuses are eligible, whether a record has
/// expired, whether its environment is compatible, and how candidates are ranked are all Core's
/// decisions, made over what this port returns.
/// </para>
/// </remarks>
public interface IExperienceCandidateSource
{
/// <summary>
/// Finds records within exactly <see cref="ExperienceCandidateQuery.Scope"/> whose indexed task
/// text matches <see cref="ExperienceCandidateQuery.TaskText"/>, whose
/// <see cref="ExperienceRecord.Status"/> is one of
/// <see cref="ExperienceCandidateQuery.EligibleStatuses"/>, and whose
/// <see cref="ExperienceRecord.ReuseConfidence"/> is at least
/// <see cref="ExperienceCandidateQuery.MinimumConfidence"/>. At most
/// <see cref="ExperienceCandidateQuery.Limit"/> records are returned, the strongest text matches
/// first.
/// </summary>
/// <param name="authorization">What the host has established the caller may do.</param>
/// <param name="query">The scoped search. Never treated as authority.</param>
/// <param name="cancellationToken">Cancels the operation.</param>
/// <returns><see cref="ExperienceStoreOutcome.Found"/> (possibly with no candidates), <see cref="ExperienceStoreOutcome.Invalid"/>, or <see cref="ExperienceStoreOutcome.Denied"/>.</returns>
Task<ExperienceCandidateSearchResult> SearchAsync(
AuthorizationContext authorization,
ExperienceCandidateQuery query,
CancellationToken cancellationToken);
}

/// <summary>
/// A scoped, text-matched search for reusable Experience Records.
/// </summary>
/// <param name="Scope">The exact scope to search within. Never treated as authority.</param>
/// <param name="TaskText">The task text to match against. Must be non-blank and at most <see cref="MaxTaskTextLength"/> characters.</param>
/// <param name="EligibleStatuses">The statuses a record must be in to be returned. Must be non-empty and contain only defined values; the caller decides which statuses are eligible.</param>
/// <param name="MinimumConfidence">The smallest <see cref="ExperienceRecord.ReuseConfidence"/> a record may have and still be returned, in [0, 1].</param>
/// <param name="Limit">Maximum number of candidates to return, from <see cref="MinLimit"/> to <see cref="MaxLimit"/>. Defaults to <see cref="DefaultLimit"/>.</param>
public sealed record ExperienceCandidateQuery(
Scope Scope,
string TaskText,
IReadOnlyList<ExperienceStatus> EligibleStatuses,
double MinimumConfidence,
int Limit = ExperienceCandidateQuery.DefaultLimit)
{
/// <summary>
/// The longest permitted <see cref="TaskText"/>. A task description is a sentence or a paragraph;
/// bounding it here keeps an accidental multi-megabyte payload a typed
/// <see cref="ExperienceStoreOutcome.Invalid"/> rather than something the text-search parser chokes
/// on deep inside the database.
/// </summary>
public const int MaxTaskTextLength = 4096;

/// <summary>The smallest permitted <see cref="Limit"/>.</summary>
public const int MinLimit = 1;

/// <summary>The largest permitted <see cref="Limit"/>.</summary>
public const int MaxLimit = 200;

/// <summary>The <see cref="Limit"/> used when none is specified.</summary>
public const int DefaultLimit = 50;
}

/// <summary>
/// One record a search matched, with how strongly its indexed text matched the query.
/// </summary>
/// <param name="Record">The matching record, read back in full.</param>
/// <param name="Relevance">
/// How strongly the record's indexed text matched, normalized to [0, 1] by the implementation, where
/// 0 is no measurable match and 1 is the strongest the implementation can report. Comparable only
/// between candidates from the same search.
/// </param>
/// <param name="SharedByGrant">
/// <see langword="true"/> when this record does not belong to the requested scope and was matched
/// only because an active <see cref="ExperienceGrant"/> permits that scope to read it. Only the
/// implementation that applied the scope predicate knows this, so only it may set it: a caller must
/// never infer sharing from comparing scopes, and a consumer must treat an unset flag as "this record
/// is the requester's own". It exists so a consumer can keep the strict scope check it would
/// otherwise have to weaken, and so borrowed experience can be labelled as such.
/// </param>
public sealed record ExperienceCandidate(ExperienceRecord Record, double Relevance, bool SharedByGrant = false);

/// <summary>
/// The result of <see cref="IExperienceCandidateSource.SearchAsync"/>.
/// </summary>
/// <param name="Outcome">What happened.</param>
/// <param name="Candidates">The matching candidates, strongest match first, when <see cref="Outcome"/> is <see cref="ExperienceStoreOutcome.Found"/>; otherwise empty.</param>
/// <param name="Errors">Every validation error when <see cref="Outcome"/> is <see cref="ExperienceStoreOutcome.Invalid"/>; otherwise empty.</param>
public sealed record ExperienceCandidateSearchResult(
ExperienceStoreOutcome Outcome,
IReadOnlyList<ExperienceCandidate> Candidates,
IReadOnlyList<StoreValidationError> Errors);
Loading
Loading