AI-Studio/app/MindWork AI Studio/Models/Matching/MatchPattern.cs
Thorsten Sommer d85b4e71b6
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Rebuilt how AI Studio knows what a model can do (#960)
2026-09-13 14:17:25 +02:00

165 lines
6.9 KiB
C#

using AIStudio.Provider;
namespace AIStudio.Models.Matching;
/// <summary>
/// What a rule says about the names it answers for.
/// </summary>
/// <remarks>
/// A pattern is written in the normalized form a model name is brought into: lowercase, hyphens
/// between the parts, dots kept. A pattern which is not in that form can never match anything, so
/// it is a mistake rather than a rule which happens to be quiet.
///
/// The extra conditions and the bindings are not only there to narrow a pattern down. They also
/// make it more specific, which is how a rule earns the right to win against a shorter one without
/// anybody writing an order.
/// </remarks>
public sealed record MatchPattern
{
/// <summary>
/// How tightly the text is bound to the name.
/// </summary>
public required MatchKind Kind { get; init; }
/// <summary>
/// The text to look for, in normalized form.
/// </summary>
public required string Text { get; init; }
/// <summary>
/// Name parts which have to be present as well.
/// </summary>
/// <remarks>
/// Each one is looked for as a whole name part, the same way the SEGMENT kind looks for its
/// text. Writing a hyphen into one of these is therefore both unnecessary and impossible: it
/// would not be a normalized pattern any more.
/// </remarks>
public IReadOnlyList<string> AlsoContains { get; init; } = [];
/// <summary>
/// Name parts whose presence rules this pattern out.
/// </summary>
public IReadOnlyList<string> NotContains { get; init; } = [];
/// <summary>
/// The provider this rule is written for, or null when it holds anywhere.
/// </summary>
/// <remarks>
/// This is what settles the cases where one name means two models depending on who serves it.
/// On Alibaba, "qwq" is qwq-plus, a commercial model; everywhere else it is the open weights
/// built on Qwen 2.5. Two rules, one of them bound.
/// </remarks>
public LLMProviders? OnlyOn { get; init; }
/// <summary>
/// The vendor this rule is written for, or null when it holds for any.
/// </summary>
/// <remarks>
/// A gateway which unwraps "anthropic/claude-sonnet-4-0" knows who built the model, and a rule
/// may insist on that instead of trusting a name.
/// </remarks>
public ModelVendor? OnlyFrom { get; init; }
/// <summary>
/// Moves this rule ahead of, or behind, everything the computed specificity would decide.
/// </summary>
/// <remarks>
/// The emergency exit, and it is meant to stay unused: the whole point of computing specificity
/// is that nobody writes an order by hand any more. A rule which sets this needs a comment
/// saying what the computation gets wrong, because the next person will read the rank as noise
/// otherwise. Negative values push a rule back.
/// </remarks>
public int ExplicitRank { get; init; }
/// <summary>
/// Whether every text of this pattern is written in normalized form.
/// </summary>
/// <remarks>
/// Normalizing is idempotent, so a text is normalized exactly when normalizing does not change
/// it. The compile time rule checks the same thing; this is what the tests and the verification
/// run use, and what catches a pattern which arrived from a plugin rather than from source.
/// </remarks>
public bool IsWellFormed => IsNormalized(this.Text) && this.AlsoContains.All(IsNormalized) && this.NotContains.All(IsNormalized);
/// <summary>
/// Whether this pattern answers for the given model.
/// </summary>
/// <param name="id">The model name, already normalized.</param>
/// <param name="provider">Who serves the model.</param>
/// <param name="vendor">Who built it, as far as anybody knows.</param>
/// <returns>True, when the rule applies.</returns>
public bool Matches(in ModelId id, LLMProviders provider, ModelVendor vendor)
{
if (this.OnlyOn is not null && this.OnlyOn.Value != provider)
return false;
if (this.OnlyFrom is not null && this.OnlyFrom.Value != vendor)
return false;
if (!this.MatchesText(id))
return false;
foreach (var required in this.AlsoContains)
if (!id.ContainsSegments(required))
return false;
foreach (var forbidden in this.NotContains)
if (id.ContainsSegments(forbidden))
return false;
return true;
}
/// <summary>
/// The name part the index files this pattern under, or an empty span when it cannot file it.
/// </summary>
/// <remarks>
/// A pattern which is bound to the start of a name, or to whole name parts, always begins at a
/// name part, so the first part of the pattern has to appear as a part of any name it matches.
/// That is what lets the index skip it for every other name. A substring pattern makes no such
/// promise and has to be checked against every name.
/// </remarks>
/// <returns>The first name part of the pattern, or empty.</returns>
public ReadOnlySpan<char> IndexKey()
{
if (this.Kind is MatchKind.SUBSTRING || string.IsNullOrWhiteSpace(this.Text))
return [];
var text = this.Text.AsSpan();
var separator = text.IndexOf(ModelId.SEGMENT_SEPARATOR);
return separator is -1 ? text : text[..separator];
}
/// <summary>
/// Everything about this pattern which decides what it matches, as one line of text.
/// </summary>
/// <remarks>
/// Two patterns with the same signature match exactly the same names, which is how the index
/// finds the rules that collide without having to reason about what a pattern could match. The
/// conditions are sorted, because stating them in a different order states the same thing.
/// </remarks>
/// <returns>The signature.</returns>
public string Signature()
{
var required = string.Join(',', this.AlsoContains.Order(StringComparer.Ordinal));
var forbidden = string.Join(',', this.NotContains.Order(StringComparer.Ordinal));
return $"{this.Kind}|{this.Text}|{this.OnlyOn}|{this.OnlyFrom}|+{required}|-{forbidden}";
}
/// <summary>
/// Whether a text is written the way a normalized model name is written.
/// </summary>
/// <param name="text">The text to check.</param>
/// <returns>True, when normalizing it would change nothing.</returns>
public static bool IsNormalized(string text) => !string.IsNullOrEmpty(text) && string.Equals(new ModelId(text).Normalized, text, StringComparison.Ordinal);
private bool MatchesText(in ModelId id) => this.Kind switch
{
MatchKind.EXACT => id.EqualsText(this.Text),
MatchKind.PREFIX => id.StartsWithSegments(this.Text),
MatchKind.SEGMENT => id.ContainsSegments(this.Text),
MatchKind.SUBSTRING => id.ContainsText(this.Text),
_ => false,
};
}