Phase 3: typed questions over a retrieved context (POST /api/v1/ask), TypeSafe judge adapter #5
@@ -0,0 +1,41 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Wiki.Core.Ports;
|
||||
|
||||
namespace Wiki.Adapters.TypeSafe;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers an <see cref="IJudge"/> backed by TypeSafe's System One API, keyed by <paramref name="name"/>.
|
||||
/// Several judges may be registered under different names, each with its own <see cref="TypeSafeOptions"/>.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddTypeSafeJudge(
|
||||
this IServiceCollection services,
|
||||
string name,
|
||||
Action<TypeSafeOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
var options = new TypeSafeOptions();
|
||||
configure(options);
|
||||
options.Validate();
|
||||
|
||||
var httpClientName = $"typesafe-judge:{name}";
|
||||
|
||||
services.AddHttpClient(httpClientName, client =>
|
||||
{
|
||||
client.Timeout = options.Timeout;
|
||||
});
|
||||
|
||||
services.AddKeyedSingleton<IJudge>(name, (sp, _) =>
|
||||
{
|
||||
var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
var client = httpClientFactory.CreateClient(httpClientName);
|
||||
return new TypeSafeJudge(client, options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Wiki.Core.Model;
|
||||
using Wiki.Core.Ports;
|
||||
|
||||
namespace Wiki.Adapters.TypeSafe;
|
||||
|
||||
/// <summary>
|
||||
/// Judges typed questions via TypeSafe's System One API. One HTTP request per <see cref="JudgeAsync"/>
|
||||
/// call, retried on 429/529 with the server's Retry-After (or exponential backoff), never retried
|
||||
/// for anything else.
|
||||
/// </summary>
|
||||
public sealed class TypeSafeJudge : IJudge
|
||||
{
|
||||
private const int ChoiceCap = 255;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly TypeSafeOptions _options;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
|
||||
|
||||
public TypeSafeJudge(HttpClient http, TypeSafeOptions options, Func<TimeSpan, CancellationToken, Task>? delay = null)
|
||||
{
|
||||
_http = http;
|
||||
_options = options;
|
||||
_delay = delay ?? Task.Delay;
|
||||
}
|
||||
|
||||
public string ModelId => _options.ModelId;
|
||||
|
||||
public int? MaxChoiceOptions => ChoiceCap;
|
||||
|
||||
public string Name => $"typesafe:{ModelId}";
|
||||
|
||||
public async Task<JudgeResult> JudgeAsync(JudgeRequest request, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
foreach (var question in request.Questions.Values)
|
||||
{
|
||||
if (question is TypedQuestion.Choice choice && choice.Options.Count > ChoiceCap)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Choice has {choice.Options.Count} options; TypeSafe's cap is {ChoiceCap}.", nameof(request));
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Questions.Count == 0)
|
||||
{
|
||||
return new JudgeResult(new Dictionary<string, Judgement>(), JudgeUsage.None);
|
||||
}
|
||||
|
||||
var body = BuildRequestBody(request);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var response = await PostWithRetryAsync(body, ct).ConfigureAwait(false);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
using var document = JsonDocument.Parse(responseBody);
|
||||
var root = document.RootElement;
|
||||
|
||||
var answers = new Dictionary<string, Judgement>();
|
||||
foreach (var property in root.GetProperty("answers").EnumerateObject())
|
||||
{
|
||||
answers[property.Name] = ParseAnswer(property.Value);
|
||||
}
|
||||
|
||||
var usageElement = root.GetProperty("usage");
|
||||
var usage = new JudgeUsage(
|
||||
1,
|
||||
usageElement.GetProperty("input_tokens").GetInt64(),
|
||||
usageElement.GetProperty("output_tokens").GetInt64(),
|
||||
stopwatch.Elapsed);
|
||||
|
||||
return new JudgeResult(answers, usage);
|
||||
}
|
||||
|
||||
public async Task<AdapterHealth> CheckAsync(CancellationToken ct)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new JudgeRequest(new { ping = true }, new Dictionary<string, TypedQuestion>
|
||||
{
|
||||
["ping"] = new TypedQuestion.Noul("Is ping true?", "it is true", "it is not true"),
|
||||
});
|
||||
|
||||
var result = await JudgeAsync(request, ct).ConfigureAwait(false);
|
||||
var detail = $"{result.Usage.InputTokens} input / {result.Usage.OutputTokens} output tokens";
|
||||
return new AdapterHealth(Name, true, detail, stopwatch.Elapsed, DateTimeOffset.UtcNow);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException)
|
||||
{
|
||||
return new AdapterHealth(Name, false, ex.Message, stopwatch.Elapsed, DateTimeOffset.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject BuildRequestBody(JudgeRequest request)
|
||||
{
|
||||
var questions = new JsonObject();
|
||||
foreach (var (id, question) in request.Questions)
|
||||
{
|
||||
questions[id] = SerializeQuestion(question);
|
||||
}
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["model"] = _options.Model,
|
||||
["state"] = JsonSerializer.SerializeToNode(request.State, JsonOptions),
|
||||
["questions"] = questions,
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonNode SerializeQuestion(TypedQuestion question) => question switch
|
||||
{
|
||||
TypedQuestion.Noul noul => new JsonObject
|
||||
{
|
||||
["type"] = "noul",
|
||||
["instructions"] = noul.Instructions,
|
||||
["criteria"] = new JsonObject
|
||||
{
|
||||
["true"] = noul.TrueCriterion,
|
||||
["false"] = noul.FalseCriterion,
|
||||
},
|
||||
},
|
||||
TypedQuestion.Choice choice => new JsonObject
|
||||
{
|
||||
["type"] = "choice",
|
||||
["instructions"] = choice.Instructions,
|
||||
["criteria"] = new JsonObject(choice.Options.Select(o => KeyValuePair.Create(o.Key, (JsonNode?)JsonValue.Create(o.Value)))),
|
||||
},
|
||||
TypedQuestion.Score score => new JsonObject
|
||||
{
|
||||
["type"] = "score",
|
||||
["instructions"] = score.Instructions,
|
||||
["criteria"] = score.Criteria,
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(question), question, "Unknown TypedQuestion."),
|
||||
};
|
||||
|
||||
private static Judgement ParseAnswer(JsonElement value)
|
||||
{
|
||||
if (value.TryGetProperty("noul", out var noul))
|
||||
{
|
||||
return new Judgement.Noul(noul.GetDouble());
|
||||
}
|
||||
|
||||
if (value.TryGetProperty("choice", out var choice))
|
||||
{
|
||||
var probabilities = new Dictionary<string, double>();
|
||||
foreach (var probability in value.GetProperty("probabilities").EnumerateObject())
|
||||
{
|
||||
probabilities[probability.Name] = probability.Value.GetDouble();
|
||||
}
|
||||
|
||||
return new Judgement.Choice(choice.GetString()!, probabilities, value.GetProperty("confidence").GetDouble());
|
||||
}
|
||||
|
||||
if (value.TryGetProperty("score", out var score))
|
||||
{
|
||||
return new Judgement.Score(score.GetDouble());
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"typesafe: answer had none of noul/choice/score: {value.GetRawText()}");
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> PostWithRetryAsync(JsonObject body, CancellationToken ct)
|
||||
{
|
||||
var json = body.ToJsonString(JsonOptions);
|
||||
var delay = TimeSpan.FromSeconds(1);
|
||||
|
||||
// A missing key is a runtime condition the status page shows, not a reason the host cannot start.
|
||||
if (string.IsNullOrWhiteSpace(_options.ApiKey))
|
||||
throw new InvalidOperationException("TypeSafe API key is not configured.");
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
using var requestMessage = new HttpRequestMessage(HttpMethod.Post, _options.Url)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
|
||||
|
||||
var response = await _http.SendAsync(requestMessage, ct).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
if ((statusCode == 429 || statusCode == 529) && attempt < _options.MaxRetries)
|
||||
{
|
||||
var wait = RetryAfter(response) ?? delay;
|
||||
response.Dispose();
|
||||
await _delay(wait, ct).ConfigureAwait(false);
|
||||
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 30));
|
||||
continue;
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
response.Dispose();
|
||||
var truncated = errorBody.Length > 500 ? errorBody[..500] : errorBody;
|
||||
throw new HttpRequestException($"typesafe: HTTP {statusCode}: {truncated}");
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan? RetryAfter(HttpResponseMessage response)
|
||||
{
|
||||
var header = response.Headers.RetryAfter;
|
||||
if (header is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (header.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
|
||||
if (header.Date is { } date)
|
||||
{
|
||||
var diff = date - DateTimeOffset.UtcNow;
|
||||
return diff > TimeSpan.Zero ? diff : TimeSpan.Zero;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Wiki.Adapters.TypeSafe;
|
||||
|
||||
/// <summary>Configuration for one TypeSafe System One judge.</summary>
|
||||
public sealed class TypeSafeOptions
|
||||
{
|
||||
private string? _modelId;
|
||||
|
||||
/// <summary>The System One endpoint.</summary>
|
||||
public string Url { get; set; } = "https://api.typesafe.ai/v1/systemone";
|
||||
|
||||
/// <summary>Model name sent in the request body.</summary>
|
||||
public string Model { get; set; } = "jev-latest";
|
||||
|
||||
/// <summary>Identifier reported as <see cref="Wiki.Core.Ports.IJudge.ModelId"/>. Defaults to <see cref="Model"/>.</summary>
|
||||
public string ModelId
|
||||
{
|
||||
get => string.IsNullOrWhiteSpace(_modelId) ? Model : _modelId;
|
||||
set => _modelId = value;
|
||||
}
|
||||
|
||||
/// <summary>Bearer token sent as Authorization.</summary>
|
||||
public string ApiKey { get; set; } = "";
|
||||
|
||||
/// <summary>Per-request timeout.</summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>How many times a 429/529 response is retried before giving up.</summary>
|
||||
public int MaxRetries { get; set; } = 5;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Url))
|
||||
{
|
||||
throw new ArgumentException("Url must be set.", nameof(Url));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Model))
|
||||
{
|
||||
throw new ArgumentException("Model must be set.", nameof(Model));
|
||||
}
|
||||
|
||||
if (MaxRetries < 0)
|
||||
{
|
||||
throw new ArgumentException("MaxRetries must not be negative.", nameof(MaxRetries));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
<ProjectReference Include="..\Wiki.Core\Wiki.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.12" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
using System.Diagnostics;
|
||||
using Wiki.Application.Retrieval;
|
||||
using Wiki.Core.Model;
|
||||
using Wiki.Core.Ports;
|
||||
|
||||
namespace Wiki.Application.Ask;
|
||||
|
||||
/// <summary>A caller's question, before expansion. Context choices are expanded over the context's paragraph or document ids.</summary>
|
||||
public abstract record AskQuestion(string Instructions)
|
||||
{
|
||||
public sealed record Noul(string Instructions, string TrueCriterion, string FalseCriterion) : AskQuestion(Instructions);
|
||||
public sealed record Choice(string Instructions, IReadOnlyDictionary<string, string> Options) : AskQuestion(Instructions);
|
||||
public sealed record ContextChoice(string Instructions, ContextScope Scope) : AskQuestion(Instructions);
|
||||
public sealed record Score(string Instructions, string Criteria) : AskQuestion(Instructions);
|
||||
}
|
||||
|
||||
public enum ContextScope
|
||||
{
|
||||
Paragraphs,
|
||||
Documents,
|
||||
}
|
||||
|
||||
public sealed record AskOptions(int K = 6, int DocChars = 12_000, int StateChars = 60_000, int MaxParallel = 8);
|
||||
|
||||
public sealed record AskRequest(IReadOnlyList<string> Queries, IReadOnlyDictionary<string, AskQuestion>? Questions, AskOptions Options);
|
||||
|
||||
public sealed record ContextDocument(string Slug, string Title, IReadOnlyList<Paragraph> Paragraphs, bool Truncated, float RankScore);
|
||||
|
||||
public sealed record RetrievalMeta(IReadOnlyList<string> DroppedForBudget, int Chars, int ParagraphCount, IReadOnlyDictionary<string, float> RankScoreBySlug);
|
||||
|
||||
/// <summary>A resolved context hit: a paragraph (with its text) or a document (with its title).</summary>
|
||||
public sealed record ContextHit(string Id, double P, string Slug, string Title, string? Heading, string? Text);
|
||||
|
||||
public abstract record AskAnswer
|
||||
{
|
||||
public sealed record Noul(double Probability) : AskAnswer;
|
||||
public sealed record Choice(string Selected, IReadOnlyDictionary<string, double> Probabilities, double Confidence) : AskAnswer;
|
||||
public sealed record Context(IReadOnlyList<ContextHit> Top, double NoneP, double Confidence) : AskAnswer;
|
||||
public sealed record Score(double Value) : AskAnswer;
|
||||
}
|
||||
|
||||
public sealed record AskResult(
|
||||
IReadOnlyList<string> Queries,
|
||||
IReadOnlyList<ContextDocument> Context,
|
||||
RetrievalMeta Retrieval,
|
||||
IReadOnlyDictionary<string, AskAnswer> Answers,
|
||||
double? Covered,
|
||||
JudgeUsage Usage,
|
||||
string JudgeModel,
|
||||
TimeSpan Elapsed);
|
||||
|
||||
/// <summary>
|
||||
/// wiki_ask.py as a use case: rank every query, unite the top notes into one context (per-document
|
||||
/// and total budget), expand context choices over the paragraph or document ids plus NONE, add the
|
||||
/// covered gate, judge (every context choice in its own request, the rest batched), and attach the
|
||||
/// answering paragraphs' text to the results. Judgements and quotations, never prose.
|
||||
/// </summary>
|
||||
public sealed class AskUseCase(RankUseCase rank, IDocumentCatalog catalog, IChunker chunker)
|
||||
{
|
||||
public const string None = "NONE";
|
||||
public const string CoveredId = "_covered";
|
||||
|
||||
public async Task<AskResult> AskAsync(AskRequest request, IEmbeddingModel model, IJudge judge, CancellationToken ct)
|
||||
{
|
||||
if (request.Queries.Count == 0) throw new ArgumentException("at least one query is required", nameof(request));
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
var (context, meta) = await BuildContextAsync(request.Queries, model, request.Options, ct);
|
||||
|
||||
var questions = request.Questions ?? DefaultQuestions(request.Queries);
|
||||
var (expanded, scopes) = Expand(questions, context, request.Queries, judge.MaxChoiceOptions);
|
||||
|
||||
var (answers, usage) = await JudgeInParallelAsync(judge, context, expanded, scopes, request.Options.MaxParallel, ct);
|
||||
var resolved = Resolve(answers, scopes, context);
|
||||
double? covered = answers.TryGetValue(CoveredId, out var c) && c is Judgement.Noul n ? Math.Round(n.Probability, 3) : null;
|
||||
return new AskResult(request.Queries, context, meta, resolved, covered, usage, judge.ModelId, Stopwatch.GetElapsedTime(started));
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<ContextDocument> Context, RetrievalMeta Meta)> BuildContextAsync(
|
||||
IReadOnlyList<string> queries, IEmbeddingModel model, AskOptions options, CancellationToken ct)
|
||||
{
|
||||
var order = new Dictionary<string, float>();
|
||||
foreach (var q in queries)
|
||||
{
|
||||
var rows = await rank.RankAsync(model, q, options.K, ct);
|
||||
for (var pos = 0; pos < rows.Count; pos++)
|
||||
{
|
||||
var score = rows[pos].Score > 0 ? rows[pos].Score : 1e-6f / (pos + 1);
|
||||
order[rows[pos].Slug] = Math.Max(order.GetValueOrDefault(rows[pos].Slug), score);
|
||||
}
|
||||
}
|
||||
|
||||
var docs = new List<ContextDocument>();
|
||||
var dropped = new List<string>();
|
||||
var used = 0;
|
||||
foreach (var slug in order.Keys.OrderByDescending(s => order[s]).ThenBy(s => s))
|
||||
{
|
||||
var document = await catalog.GetAsync(DocumentKind.Wiki, slug, ct);
|
||||
if (document is null) continue;
|
||||
var (paragraphs, truncated) = Cap(chunker.Split(document), options.DocChars);
|
||||
var size = document.Title.Length + paragraphs.Sum(p => p.Text.Length + p.Heading.Length);
|
||||
if (used + size > options.StateChars && docs.Count > 0)
|
||||
{
|
||||
dropped.Add(slug);
|
||||
continue;
|
||||
}
|
||||
docs.Add(new ContextDocument(slug, document.Title, paragraphs, truncated, order[slug]));
|
||||
used += size;
|
||||
}
|
||||
var meta = new RetrievalMeta(dropped, used, docs.Sum(d => d.Paragraphs.Count),
|
||||
order.ToDictionary(kv => kv.Key, kv => (float)Math.Round(kv.Value, 3)));
|
||||
return (docs, meta);
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<Paragraph> Paragraphs, bool Truncated) Cap(IReadOnlyList<Paragraph> paragraphs, int docChars)
|
||||
{
|
||||
var kept = new List<Paragraph>();
|
||||
var used = 0;
|
||||
foreach (var p in paragraphs)
|
||||
{
|
||||
var size = p.Text.Length + p.Heading.Length;
|
||||
if (kept.Count > 0 && used + size > docChars) break;
|
||||
kept.Add(p);
|
||||
used += size;
|
||||
}
|
||||
return (kept, kept.Count < paragraphs.Count);
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, AskQuestion> DefaultQuestions(IReadOnlyList<string> queries)
|
||||
{
|
||||
var qs = new Dictionary<string, AskQuestion>();
|
||||
for (var i = 0; i < queries.Count; i++)
|
||||
{
|
||||
var q = queries[i];
|
||||
qs[$"answered_{i + 1}"] = new AskQuestion.Noul(
|
||||
$"Do the `documents` answer this question: {q}",
|
||||
"At least one paragraph states what the question asks for.",
|
||||
"The documents only touch the topic or do not address the question.");
|
||||
qs[$"where_{i + 1}"] = new AskQuestion.ContextChoice(
|
||||
$"Which paragraph (by `id`) most directly answers this question: {q} — pick {None} if none does.",
|
||||
ContextScope.Paragraphs);
|
||||
}
|
||||
return qs;
|
||||
}
|
||||
|
||||
public static (IReadOnlyDictionary<string, TypedQuestion> Questions, IReadOnlyDictionary<string, ContextScope> Scopes) Expand(
|
||||
IReadOnlyDictionary<string, AskQuestion> questions, IReadOnlyList<ContextDocument> context, IReadOnlyList<string> queries, int? maxOptions)
|
||||
{
|
||||
var paragraphIds = context.SelectMany(d => d.Paragraphs.Select(p => p.Id)).ToList();
|
||||
var slugs = context.Select(d => d.Slug).ToList();
|
||||
var expanded = new Dictionary<string, TypedQuestion>();
|
||||
var scopes = new Dictionary<string, ContextScope>();
|
||||
foreach (var (id, q) in questions)
|
||||
{
|
||||
switch (q)
|
||||
{
|
||||
case AskQuestion.Noul n:
|
||||
expanded[id] = new TypedQuestion.Noul(n.Instructions, n.TrueCriterion, n.FalseCriterion);
|
||||
break;
|
||||
case AskQuestion.Choice c:
|
||||
expanded[id] = new TypedQuestion.Choice(c.Instructions, c.Options);
|
||||
break;
|
||||
case AskQuestion.Score s:
|
||||
expanded[id] = new TypedQuestion.Score(s.Instructions, s.Criteria);
|
||||
break;
|
||||
case AskQuestion.ContextChoice cc:
|
||||
var ids = cc.Scope == ContextScope.Paragraphs ? paragraphIds : slugs;
|
||||
if (maxOptions is { } cap && ids.Count + 1 > cap)
|
||||
throw new ArgumentException($"{id}: {ids.Count} {cc.Scope.ToString().ToLowerInvariant()} exceed the {cap - 1}-option Choice cap; lower k");
|
||||
var options = ids.ToDictionary(i => i, _ => string.Empty);
|
||||
options[None] = $"No {(cc.Scope == ContextScope.Paragraphs ? "paragraph" : "document")} in the documents fits";
|
||||
expanded[id] = new TypedQuestion.Choice(cc.Instructions, options);
|
||||
scopes[id] = cc.Scope;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!expanded.ContainsKey(CoveredId))
|
||||
{
|
||||
expanded[CoveredId] = new TypedQuestion.Noul(
|
||||
"Do the `documents` together contain enough to answer these retrieval queries: " + string.Join(" | ", queries),
|
||||
"The queries can be answered from the documents, possibly combining several.",
|
||||
"The documents only touch the topic; a real answer would need material not here.");
|
||||
}
|
||||
return (expanded, scopes);
|
||||
}
|
||||
|
||||
private static async Task<(IReadOnlyDictionary<string, Judgement> Answers, JudgeUsage Usage)> JudgeInParallelAsync(
|
||||
IJudge judge, IReadOnlyList<ContextDocument> context, IReadOnlyDictionary<string, TypedQuestion> questions,
|
||||
IReadOnlyDictionary<string, ContextScope> scopes, int maxParallel, CancellationToken ct)
|
||||
{
|
||||
var state = new
|
||||
{
|
||||
documents = context.Select(d => new
|
||||
{
|
||||
slug = d.Slug,
|
||||
title = d.Title,
|
||||
paragraphs = d.Paragraphs.Select(p => new { id = p.Id, heading = p.Heading, text = p.Text }),
|
||||
}),
|
||||
};
|
||||
var groups = new List<Dictionary<string, TypedQuestion>>();
|
||||
foreach (var (id, q) in questions.Where(kv => scopes.ContainsKey(kv.Key)))
|
||||
groups.Add(new Dictionary<string, TypedQuestion> { [id] = q });
|
||||
var rest = questions.Where(kv => !scopes.ContainsKey(kv.Key)).ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
if (rest.Count > 0) groups.Add(rest);
|
||||
|
||||
using var gate = new SemaphoreSlim(Math.Max(1, maxParallel));
|
||||
var tasks = groups.Select(async g =>
|
||||
{
|
||||
await gate.WaitAsync(ct);
|
||||
try { return await judge.JudgeAsync(new JudgeRequest(state, g), ct); }
|
||||
finally { gate.Release(); }
|
||||
}).ToList();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
var answers = new Dictionary<string, Judgement>();
|
||||
var usage = JudgeUsage.None;
|
||||
foreach (var r in results)
|
||||
{
|
||||
foreach (var (id, a) in r.Answers) answers[id] = a;
|
||||
usage = usage.Add(r.Usage);
|
||||
}
|
||||
return (answers, usage);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, AskAnswer> Resolve(
|
||||
IReadOnlyDictionary<string, Judgement> answers, IReadOnlyDictionary<string, ContextScope> scopes, IReadOnlyList<ContextDocument> context)
|
||||
{
|
||||
var byParagraph = context.SelectMany(d => d.Paragraphs.Select(p => (p, d))).ToDictionary(x => x.p.Id, x => x);
|
||||
var titles = context.ToDictionary(d => d.Slug, d => d.Title);
|
||||
var resolved = new Dictionary<string, AskAnswer>();
|
||||
foreach (var (id, a) in answers)
|
||||
{
|
||||
if (scopes.TryGetValue(id, out var scope) && a is Judgement.Choice c)
|
||||
{
|
||||
var top = c.Probabilities.Where(kv => kv.Key != None && kv.Value >= 0.03)
|
||||
.OrderByDescending(kv => kv.Value).Take(3)
|
||||
.Select(kv => scope == ContextScope.Paragraphs && byParagraph.TryGetValue(kv.Key, out var hit)
|
||||
? new ContextHit(kv.Key, Math.Round(kv.Value, 3), hit.d.Slug, hit.d.Title, hit.p.Heading, hit.p.Text)
|
||||
: new ContextHit(kv.Key, Math.Round(kv.Value, 3), kv.Key, titles.GetValueOrDefault(kv.Key, kv.Key), null, null))
|
||||
.ToList();
|
||||
resolved[id] = new AskAnswer.Context(top, Math.Round(c.Probabilities.GetValueOrDefault(None), 3), c.Confidence);
|
||||
continue;
|
||||
}
|
||||
resolved[id] = a switch
|
||||
{
|
||||
Judgement.Noul n => new AskAnswer.Noul(n.Probability),
|
||||
Judgement.Choice ch => new AskAnswer.Choice(ch.Selected, ch.Probabilities, ch.Confidence),
|
||||
Judgement.Score s => new AskAnswer.Score(s.Value),
|
||||
_ => throw new InvalidOperationException($"unknown judgement for {id}"),
|
||||
};
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public static class ApiV1
|
||||
{
|
||||
var v1 = app.MapGroup("/api/v1");
|
||||
v1.MapReadEndpoints();
|
||||
v1.MapAskEndpoints();
|
||||
|
||||
v1.MapGet("/status", async (StatusQuery status, CancellationToken ct) =>
|
||||
Results.Ok(await status.GetAsync(includeAdapterChecks: true, ct))).AllowAnonymous();
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Wiki.Application.Ask;
|
||||
using Wiki.Core.Ports;
|
||||
|
||||
namespace Wiki.Host.Api;
|
||||
|
||||
/// <summary>Wire shape of a caller's question, as bin/wiki_ask.py's --questions JSON: criteria is a
|
||||
/// {true,false} object for noul, an options object or the strings "paragraphs"/"documents" for choice, a string for score.</summary>
|
||||
public sealed record AskQuestionDto(string Type, string Instructions, JsonElement? Criteria);
|
||||
|
||||
public sealed record AskRequestDto(
|
||||
[property: JsonPropertyName("queries")] IReadOnlyList<string>? Queries,
|
||||
[property: JsonPropertyName("questions")] IReadOnlyDictionary<string, AskQuestionDto>? Questions,
|
||||
[property: JsonPropertyName("k")] int? K,
|
||||
[property: JsonPropertyName("model")] string? Model,
|
||||
[property: JsonPropertyName("judge")] string? Judge);
|
||||
|
||||
public static class AskEndpoints
|
||||
{
|
||||
public static void MapAskEndpoints(this RouteGroupBuilder v1)
|
||||
{
|
||||
v1.MapPost("/ask", async (AskRequestDto body, AskUseCase ask, IEmbeddingModel activeModel, IJudge activeJudge, IServiceProvider services, CancellationToken ct) =>
|
||||
{
|
||||
if (body.Queries is null || body.Queries.Count == 0 || body.Queries.All(string.IsNullOrWhiteSpace))
|
||||
return Results.BadRequest(new { error = "queries required" });
|
||||
|
||||
var model = activeModel;
|
||||
if (!string.IsNullOrEmpty(body.Model))
|
||||
{
|
||||
model = services.GetKeyedService<IEmbeddingModel>(body.Model);
|
||||
if (model is null) return Results.BadRequest(new { error = $"unknown model '{body.Model}'" });
|
||||
}
|
||||
var judge = activeJudge;
|
||||
if (!string.IsNullOrEmpty(body.Judge))
|
||||
{
|
||||
judge = services.GetKeyedService<IJudge>(body.Judge);
|
||||
if (judge is null) return Results.BadRequest(new { error = $"unknown judge '{body.Judge}'" });
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, AskQuestion>? questions;
|
||||
try
|
||||
{
|
||||
questions = body.Questions is null ? null : body.Questions.ToDictionary(kv => kv.Key, kv => Parse(kv.Key, kv.Value));
|
||||
}
|
||||
catch (ArgumentException e)
|
||||
{
|
||||
return Results.BadRequest(new { error = e.Message });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ask.AskAsync(new AskRequest(body.Queries.Where(q => !string.IsNullOrWhiteSpace(q)).ToList(), questions, new AskOptions(K: body.K ?? 6)), model, judge, ct);
|
||||
return Results.Ok(new
|
||||
{
|
||||
result.Queries,
|
||||
context = result.Context.Select(d => new { d.Slug, d.Title, paragraphs = d.Paragraphs.Count, d.Truncated, rankScore = d.RankScore }),
|
||||
retrieval = new { result.Retrieval.DroppedForBudget, result.Retrieval.Chars, result.Retrieval.ParagraphCount },
|
||||
answers = result.Answers.ToDictionary(kv => kv.Key, kv => (object)(kv.Value switch
|
||||
{
|
||||
AskAnswer.Noul n => new { noul = n.Probability },
|
||||
AskAnswer.Score s => new { score = s.Value },
|
||||
AskAnswer.Choice c => new { choice = c.Selected, probabilities = c.Probabilities, confidence = c.Confidence },
|
||||
AskAnswer.Context x => new { top = x.Top, none_p = x.NoneP, confidence = x.Confidence },
|
||||
_ => new { },
|
||||
})),
|
||||
result.Covered,
|
||||
usage = result.Usage,
|
||||
judge = result.JudgeModel,
|
||||
model = model.ModelId,
|
||||
elapsed_s = Math.Round(result.Elapsed.TotalSeconds, 3),
|
||||
});
|
||||
}
|
||||
catch (ArgumentException e)
|
||||
{
|
||||
return Results.BadRequest(new { error = e.Message });
|
||||
}
|
||||
catch (HttpRequestException e)
|
||||
{
|
||||
return Results.Json(new { error = e.Message }, statusCode: StatusCodes.Status502BadGateway);
|
||||
}
|
||||
}).AllowAnonymous();
|
||||
}
|
||||
|
||||
public static AskQuestion Parse(string id, AskQuestionDto dto)
|
||||
{
|
||||
var criteria = dto.Criteria;
|
||||
switch (dto.Type?.ToLowerInvariant())
|
||||
{
|
||||
case "noul":
|
||||
{
|
||||
if (criteria is not { ValueKind: JsonValueKind.Object } o || !o.TryGetProperty("true", out var t) || !o.TryGetProperty("false", out var f))
|
||||
throw new ArgumentException($"{id}: noul needs criteria {{true, false}}");
|
||||
return new AskQuestion.Noul(dto.Instructions, t.GetString() ?? string.Empty, f.GetString() ?? string.Empty);
|
||||
}
|
||||
case "choice":
|
||||
{
|
||||
if (criteria is { ValueKind: JsonValueKind.String } s)
|
||||
{
|
||||
return s.GetString() switch
|
||||
{
|
||||
"paragraphs" => new AskQuestion.ContextChoice(dto.Instructions, ContextScope.Paragraphs),
|
||||
"documents" => new AskQuestion.ContextChoice(dto.Instructions, ContextScope.Documents),
|
||||
var other => throw new ArgumentException($"{id}: unknown choice criteria '{other}'"),
|
||||
};
|
||||
}
|
||||
if (criteria is { ValueKind: JsonValueKind.Object } o)
|
||||
{
|
||||
var options = new Dictionary<string, string>();
|
||||
foreach (var p in o.EnumerateObject()) options[p.Name] = p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() ?? string.Empty : string.Empty;
|
||||
if (options.Count == 0) throw new ArgumentException($"{id}: choice needs options");
|
||||
return new AskQuestion.Choice(dto.Instructions, options);
|
||||
}
|
||||
throw new ArgumentException($"{id}: choice needs criteria (options object, \"paragraphs\" or \"documents\")");
|
||||
}
|
||||
case "score":
|
||||
return new AskQuestion.Score(dto.Instructions, criteria is { ValueKind: JsonValueKind.String } sc ? sc.GetString() ?? string.Empty : string.Empty);
|
||||
default:
|
||||
throw new ArgumentException($"{id}: unknown question type '{dto.Type}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ public sealed class WikiOptions
|
||||
{
|
||||
public NoteStoreOptions NoteStore { get; set; } = new();
|
||||
public EmbeddingsOptions Embeddings { get; set; } = new();
|
||||
public JudgesOptions Judges { get; set; } = new();
|
||||
public IndexOptions Index { get; set; } = new();
|
||||
public SyncScheduleOptions Sync { get; set; } = new();
|
||||
}
|
||||
@@ -36,6 +37,19 @@ public sealed class EmbeddingModelOptions
|
||||
public int Dimension { get; set; } = 1024;
|
||||
}
|
||||
|
||||
public sealed class JudgesOptions
|
||||
{
|
||||
public string Active { get; set; } = "typesafe";
|
||||
public Dictionary<string, JudgeModelOptions> Models { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class JudgeModelOptions
|
||||
{
|
||||
public string Adapter { get; set; } = "TypeSafe";
|
||||
public string Url { get; set; } = "https://api.typesafe.ai/v1/systemone";
|
||||
public string Model { get; set; } = "jev-latest";
|
||||
}
|
||||
|
||||
public sealed class IndexOptions
|
||||
{
|
||||
public string Active { get; set; } = "file";
|
||||
|
||||
@@ -3,6 +3,7 @@ using MudBlazor.Services;
|
||||
using Wiki.Adapters.FileIndex;
|
||||
using Wiki.Adapters.Gitea;
|
||||
using Wiki.Adapters.LlamaCpp;
|
||||
using Wiki.Adapters.TypeSafe;
|
||||
using Wiki.Application.Retrieval;
|
||||
using Wiki.Application.Status;
|
||||
using Wiki.Application.Sync;
|
||||
@@ -47,6 +48,26 @@ foreach (var (name, model) in wiki.Embeddings.Models)
|
||||
builder.Services.AddSingleton<IReadOnlyList<IEmbeddingModel>>(sp =>
|
||||
wiki.Embeddings.Models.Keys.Select(sp.GetRequiredKeyedService<IEmbeddingModel>).ToList());
|
||||
builder.Services.AddSingleton<IEmbeddingModel>(sp => sp.GetRequiredKeyedService<IEmbeddingModel>(wiki.Embeddings.Active));
|
||||
foreach (var (name, judge) in wiki.Judges.Models)
|
||||
{
|
||||
switch (judge.Adapter)
|
||||
{
|
||||
case "TypeSafe":
|
||||
builder.Services.AddTypeSafeJudge(name, o =>
|
||||
{
|
||||
o.Url = judge.Url;
|
||||
o.Model = judge.Model;
|
||||
o.ModelId = name;
|
||||
o.ApiKey = builder.Configuration[$"Wiki:Judges:Models:{name}:ApiKey"] ?? string.Empty;
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"unknown judge adapter '{judge.Adapter}' for '{name}'");
|
||||
}
|
||||
}
|
||||
builder.Services.AddSingleton<IReadOnlyList<IJudge>>(sp =>
|
||||
wiki.Judges.Models.Keys.Select(sp.GetRequiredKeyedService<IJudge>).ToList());
|
||||
builder.Services.AddSingleton<IJudge>(sp => sp.GetRequiredKeyedService<IJudge>(wiki.Judges.Active));
|
||||
// One adapter may serve several ports (Gitea is note store and fact sink); list it once.
|
||||
builder.Services.AddSingleton<IReadOnlyList<IAdapterHealth>>(sp =>
|
||||
{
|
||||
@@ -56,6 +77,7 @@ builder.Services.AddSingleton<IReadOnlyList<IAdapterHealth>>(sp =>
|
||||
sp.GetRequiredService<IVectorIndex>(),
|
||||
sp.GetRequiredService<IDocumentCatalog>(),
|
||||
.. sp.GetRequiredService<IReadOnlyList<IEmbeddingModel>>(),
|
||||
.. sp.GetRequiredService<IReadOnlyList<IJudge>>(),
|
||||
];
|
||||
return all.Distinct().ToList();
|
||||
});
|
||||
@@ -66,6 +88,8 @@ builder.Services.AddSingleton<SyncUseCase>();
|
||||
builder.Services.AddSingleton<SyncCoordinator>();
|
||||
builder.Services.AddSingleton<RankUseCase>();
|
||||
builder.Services.AddSingleton<Wiki.Application.Reading.NoteQueries>();
|
||||
builder.Services.AddSingleton<IChunker, Wiki.Core.Parsing.ParagraphChunker>();
|
||||
builder.Services.AddSingleton<Wiki.Application.Ask.AskUseCase>();
|
||||
builder.Services.AddSingleton<StatusQuery>();
|
||||
builder.Services.AddSingleton<SyncBackgroundService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<SyncBackgroundService>());
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
"bge-m3": { "Adapter": "LlamaCpp", "Url": "http://wiki-embed.ai.svc.cluster.local:8080", "Model": "bge-m3", "Dimension": 1024 }
|
||||
}
|
||||
},
|
||||
"Judges": {
|
||||
"Active": "typesafe",
|
||||
"Models": {
|
||||
"typesafe": { "Adapter": "TypeSafe", "Url": "https://api.typesafe.ai/v1/systemone", "Model": "jev-latest" }
|
||||
}
|
||||
},
|
||||
"Index": { "Active": "file", "File": { "Directory": "/data" } },
|
||||
"Sync": { "IntervalSeconds": 1800, "RetrySeconds": 60 }
|
||||
},
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Wiki.Application.Ask;
|
||||
using Wiki.Application.Retrieval;
|
||||
using Wiki.Application.Sync;
|
||||
using Wiki.Core.Model;
|
||||
using Wiki.Core.Parsing;
|
||||
using Wiki.Core.Ports;
|
||||
|
||||
namespace Wiki.Application.Tests;
|
||||
|
||||
/// <summary>A judge that records requests and answers by simple rules, so the use case's wiring is testable.</summary>
|
||||
public sealed class FakeJudge : IJudge
|
||||
{
|
||||
public List<JudgeRequest> Requests { get; } = [];
|
||||
public string ModelId => "fake-judge";
|
||||
public int? MaxChoiceOptions { get; init; } = 255;
|
||||
public string Name => "fake-judge";
|
||||
|
||||
public Task<JudgeResult> JudgeAsync(JudgeRequest request, CancellationToken ct)
|
||||
{
|
||||
Requests.Add(request);
|
||||
var answers = new Dictionary<string, Judgement>();
|
||||
foreach (var (id, q) in request.Questions)
|
||||
{
|
||||
answers[id] = q switch
|
||||
{
|
||||
TypedQuestion.Noul => new Judgement.Noul(id == AskUseCase.CoveredId ? 0.8 : 0.9),
|
||||
TypedQuestion.Choice c => Spread(c),
|
||||
TypedQuestion.Score => new Judgement.Score(0.5),
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
}
|
||||
return Task.FromResult(new JudgeResult(answers, new JudgeUsage(1, 100, request.Questions.Count, TimeSpan.FromMilliseconds(5))));
|
||||
}
|
||||
|
||||
private static Judgement.Choice Spread(TypedQuestion.Choice c)
|
||||
{
|
||||
// First option gets 0.6, the rest share 0.4 equally (NONE included).
|
||||
var keys = c.Options.Keys.ToList();
|
||||
var probs = new Dictionary<string, double>();
|
||||
for (var i = 0; i < keys.Count; i++)
|
||||
probs[keys[i]] = keys.Count == 1 ? 1.0 : (i == 0 ? 0.6 : 0.4 / (keys.Count - 1));
|
||||
return new Judgement.Choice(keys[0], probs, 0.6);
|
||||
}
|
||||
|
||||
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
|
||||
}
|
||||
|
||||
public class AskUseCaseTests
|
||||
{
|
||||
private readonly FakeStore _store = new();
|
||||
private readonly FakeCatalog _catalog = new();
|
||||
private readonly FakeIndex _index = new();
|
||||
private readonly FakeEmbeddings _model = new("m1");
|
||||
private readonly FakeJudge _judge = new();
|
||||
|
||||
private AskUseCase UseCase => new(new RankUseCase(_index, _catalog), _catalog, new ParagraphChunker());
|
||||
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
_store.Files["wiki/udm-dns.md"] = "---\ntitle: UDM intercepts DNS\ntype: wiki\ntags: [dns]\n---\n\n# UDM intercepts DNS\n\nThe UDM answers every query.\n\n## Details\n\nEven dig @1.1.1.1 is answered locally.\n";
|
||||
_store.Files["wiki/ceph-osd.md"] = "---\ntitle: Ceph OSD\ntype: wiki\ntags: [ceph]\n---\n\n# Ceph OSD\n\nOSD fsid mismatch after rebuild.\n";
|
||||
_store.Files["wiki/long-note.md"] = "---\ntitle: Long\ntype: wiki\n---\n\n# Long\n\n" + string.Join("\n\n", Enumerable.Range(0, 40).Select(i => $"paragraph {i} " + new string('x', 400))) + "\n";
|
||||
var sync = new SyncUseCase(_store, _catalog, _index, [_model], TimeProvider.System, NullLogger<SyncUseCase>.Instance);
|
||||
await sync.RunAsync(false, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultQuestionsPerQueryAndCoveredGate()
|
||||
{
|
||||
await SeedAsync();
|
||||
var udm = (await _catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None))!;
|
||||
var request = new AskRequest([EmbeddingText.For(udm)], null, new AskOptions(K: 2));
|
||||
|
||||
var result = await UseCase.AskAsync(request, _model, _judge, CancellationToken.None);
|
||||
|
||||
Assert.Equal("udm-dns", result.Context[0].Slug);
|
||||
Assert.Equal(0.8, result.Covered);
|
||||
Assert.Equal(["answered_1", "where_1", AskUseCase.CoveredId], result.Answers.Keys.OrderBy(k => k == AskUseCase.CoveredId ? 2 : k.StartsWith("answered") ? 0 : 1));
|
||||
var where = Assert.IsType<AskAnswer.Context>(result.Answers["where_1"]);
|
||||
Assert.Equal("udm-dns#p1", where.Top[0].Id);
|
||||
Assert.Contains("answers every query", where.Top[0].Text);
|
||||
Assert.Equal("UDM intercepts DNS", where.Top[0].Title);
|
||||
// the context choice went alone, the noul questions together
|
||||
Assert.Equal(2, _judge.Requests.Count);
|
||||
Assert.Contains(_judge.Requests, r => r.Questions.Count == 1 && r.Questions.ContainsKey("where_1"));
|
||||
Assert.Equal(2, result.Usage.Requests);
|
||||
Assert.Equal("fake-judge", result.JudgeModel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContextChoiceOptionsAreTheParagraphIdsPlusNone()
|
||||
{
|
||||
await SeedAsync();
|
||||
var udm = (await _catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None))!;
|
||||
var request = new AskRequest([EmbeddingText.For(udm)], new Dictionary<string, AskQuestion>
|
||||
{
|
||||
["src"] = new AskQuestion.ContextChoice("which note", ContextScope.Documents),
|
||||
["fact"] = new AskQuestion.Noul("is it intercepted", "yes", "no"),
|
||||
}, new AskOptions(K: 2));
|
||||
|
||||
var result = await UseCase.AskAsync(request, _model, _judge, CancellationToken.None);
|
||||
|
||||
var sent = _judge.Requests.Single(r => r.Questions.ContainsKey("src"));
|
||||
var choice = Assert.IsType<TypedQuestion.Choice>(sent.Questions["src"]);
|
||||
Assert.Equal(result.Context.Select(d => d.Slug).Append(AskUseCase.None).OrderBy(x => x), choice.Options.Keys.OrderBy(x => x));
|
||||
Assert.Equal(string.Empty, choice.Options[result.Context[0].Slug]);
|
||||
var src = Assert.IsType<AskAnswer.Context>(result.Answers["src"]);
|
||||
Assert.Equal("udm-dns", src.Top[0].Slug);
|
||||
Assert.Null(src.Top[0].Text);
|
||||
Assert.IsType<AskAnswer.Noul>(result.Answers["fact"]);
|
||||
Assert.True(result.Answers.ContainsKey(AskUseCase.CoveredId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BudgetsCapLongDocumentsAndDropTheLowest()
|
||||
{
|
||||
await SeedAsync();
|
||||
var longNote = (await _catalog.GetAsync(DocumentKind.Wiki, "long-note", CancellationToken.None))!;
|
||||
var (context, meta) = await UseCase.BuildContextAsync([EmbeddingText.For(longNote)], _model, new AskOptions(K: 3, DocChars: 3000, StateChars: 2950), CancellationToken.None);
|
||||
Assert.Equal("long-note", context[0].Slug);
|
||||
Assert.True(context[0].Truncated);
|
||||
Assert.True(context[0].Paragraphs.Count < 40);
|
||||
Assert.Equal(2, meta.DroppedForBudget.Count);
|
||||
Assert.Single(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectsAContextChoiceOverTheJudgeCap()
|
||||
{
|
||||
await SeedAsync();
|
||||
var udm = (await _catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None))!;
|
||||
var small = new FakeJudge { MaxChoiceOptions = 3 };
|
||||
var request = new AskRequest([EmbeddingText.For(udm)], null, new AskOptions(K: 3));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => UseCase.AskAsync(request, _model, small, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Wiki.Core.Model;
|
||||
using Wiki.Core.Ports;
|
||||
|
||||
namespace Wiki.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Every <see cref="IJudge"/> adapter derives from this. The adapter under test may talk to a fake
|
||||
/// server; the fake must honour the request shape and answer every question it is asked.
|
||||
/// </summary>
|
||||
public abstract class JudgeContract
|
||||
{
|
||||
protected abstract Task<IJudge> CreateJudgeAsync();
|
||||
|
||||
private static readonly object State = new { documents = new[] { new { id = "a", text = "The UDM answers every DNS query itself." } } };
|
||||
|
||||
[Fact]
|
||||
public async Task AnswersAllThreePrimitivesInOneRequest()
|
||||
{
|
||||
var judge = await CreateJudgeAsync();
|
||||
var request = new JudgeRequest(State, new Dictionary<string, TypedQuestion>
|
||||
{
|
||||
["fact"] = new TypedQuestion.Noul("Do the documents say the UDM answers DNS itself?", "they do", "they do not"),
|
||||
["where"] = new TypedQuestion.Choice("Which document says it?", new Dictionary<string, string> { ["a"] = "document a", ["NONE"] = "none of them" }),
|
||||
["how_sure"] = new TypedQuestion.Score("How directly is it stated?", "0 = not at all, 1 = verbatim"),
|
||||
});
|
||||
|
||||
var result = await judge.JudgeAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { "fact", "how_sure", "where" }, result.Answers.Keys.OrderBy(k => k));
|
||||
var fact = Assert.IsType<Judgement.Noul>(result.Answers["fact"]);
|
||||
Assert.InRange(fact.Probability, 0.0, 1.0);
|
||||
var where = Assert.IsType<Judgement.Choice>(result.Answers["where"]);
|
||||
Assert.Contains(where.Selected, new[] { "a", "NONE" });
|
||||
Assert.Equal(new[] { "NONE", "a" }, where.Probabilities.Keys.OrderBy(k => k));
|
||||
Assert.InRange(where.Probabilities.Values.Sum(), 0.99, 1.01);
|
||||
Assert.InRange(where.Confidence, 0.0, 1.0);
|
||||
var score = Assert.IsType<Judgement.Score>(result.Answers["how_sure"]);
|
||||
Assert.InRange(score.Value, 0.0, 1.0);
|
||||
Assert.Equal(1, result.Usage.Requests);
|
||||
Assert.True(result.Usage.InputTokens > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyQuestionSetNeedsNoRequest()
|
||||
{
|
||||
var judge = await CreateJudgeAsync();
|
||||
var result = await judge.JudgeAsync(new JudgeRequest(State, new Dictionary<string, TypedQuestion>()), CancellationToken.None);
|
||||
Assert.Empty(result.Answers);
|
||||
Assert.Equal(0, result.Usage.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChoiceAboveTheCapIsRejectedBeforeSending()
|
||||
{
|
||||
var judge = await CreateJudgeAsync();
|
||||
if (judge.MaxChoiceOptions is not { } cap) return;
|
||||
var options = Enumerable.Range(0, cap + 1).ToDictionary(i => $"o{i}", i => $"option {i}");
|
||||
var request = new JudgeRequest(State, new Dictionary<string, TypedQuestion> { ["too_many"] = new TypedQuestion.Choice("pick", options) });
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => judge.JudgeAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ModelIdAndHealth()
|
||||
{
|
||||
var judge = await CreateJudgeAsync();
|
||||
Assert.False(string.IsNullOrWhiteSpace(judge.ModelId));
|
||||
var health = await judge.CheckAsync(CancellationToken.None);
|
||||
Assert.True(health.Ok, health.Detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Wiki.Contracts.TypeSafe;
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the TypeSafe System One endpoint. Validates the request shape and answers every
|
||||
/// question deterministically: noul is 0.9 when the instructions mention "UDM", else 0.1; choice
|
||||
/// spreads probability over the given options and selects the first one; score is always 0.5.
|
||||
/// Can be scripted to return a run of 429s (with Retry-After: 0) before succeeding, or a 400 with
|
||||
/// a JSON error body, to exercise the adapter's retry and error handling.
|
||||
/// </summary>
|
||||
internal sealed class FakeTypeSafeHandler : HttpMessageHandler
|
||||
{
|
||||
public int TooManyRequestsBeforeSuccess { get; set; }
|
||||
|
||||
public bool RespondBadRequest { get; set; }
|
||||
|
||||
public string BadRequestBody { get; set; } = "{\"error\":\"bad request\"}";
|
||||
|
||||
public string? LastRequestJson { get; private set; }
|
||||
|
||||
public AuthenticationHeaderValue? LastAuthorization { get; private set; }
|
||||
|
||||
private int _tooManyRequestsServed;
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastAuthorization = request.Headers.Authorization;
|
||||
var body = await request.Content!.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
LastRequestJson = body;
|
||||
|
||||
if (RespondBadRequest)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent(BadRequestBody, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
if (_tooManyRequestsServed < TooManyRequestsBeforeSuccess)
|
||||
{
|
||||
_tooManyRequestsServed++;
|
||||
var tooMany = new HttpResponseMessage((HttpStatusCode)429)
|
||||
{
|
||||
Content = new StringContent("{\"error\":\"rate limited\"}", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
tooMany.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.Zero);
|
||||
return tooMany;
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("model", out _) || !root.TryGetProperty("state", out _) || !root.TryGetProperty("questions", out var questions))
|
||||
{
|
||||
throw new InvalidOperationException($"Malformed TypeSafe request: {body}");
|
||||
}
|
||||
|
||||
var answers = new JsonObject();
|
||||
var questionCount = 0;
|
||||
foreach (var question in questions.EnumerateObject())
|
||||
{
|
||||
questionCount++;
|
||||
var type = question.Value.GetProperty("type").GetString();
|
||||
if (string.IsNullOrEmpty(question.Value.GetProperty("instructions").GetString()))
|
||||
{
|
||||
throw new InvalidOperationException("Question missing instructions.");
|
||||
}
|
||||
|
||||
if (!question.Value.TryGetProperty("criteria", out _))
|
||||
{
|
||||
throw new InvalidOperationException("Question missing criteria.");
|
||||
}
|
||||
|
||||
answers[question.Name] = type switch
|
||||
{
|
||||
"noul" => AnswerNoul(question.Value),
|
||||
"choice" => AnswerChoice(question.Value),
|
||||
"score" => AnswerScore(),
|
||||
_ => throw new InvalidOperationException($"Unknown question type: {type}"),
|
||||
};
|
||||
}
|
||||
|
||||
var responseBody = new JsonObject
|
||||
{
|
||||
["answers"] = answers,
|
||||
["usage"] = new JsonObject
|
||||
{
|
||||
["input_tokens"] = body.Length / 4,
|
||||
["output_tokens"] = questionCount,
|
||||
},
|
||||
};
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseBody.ToJsonString(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject AnswerNoul(JsonElement question)
|
||||
{
|
||||
var instructions = question.GetProperty("instructions").GetString() ?? "";
|
||||
var probability = instructions.Contains("UDM", StringComparison.Ordinal) ? 0.9 : 0.1;
|
||||
return new JsonObject { ["noul"] = probability };
|
||||
}
|
||||
|
||||
private static JsonObject AnswerChoice(JsonElement question)
|
||||
{
|
||||
var options = question.GetProperty("criteria").EnumerateObject().Select(p => p.Name).ToArray();
|
||||
var probabilities = new JsonObject();
|
||||
var share = 1.0 / options.Length;
|
||||
double firstProbability = share;
|
||||
for (var i = 0; i < options.Length; i++)
|
||||
{
|
||||
// The last option absorbs rounding so the probabilities sum to exactly 1.
|
||||
var value = i == options.Length - 1 ? 1.0 - share * (options.Length - 1) : share;
|
||||
if (i == 0)
|
||||
{
|
||||
firstProbability = value;
|
||||
}
|
||||
|
||||
probabilities[options[i]] = value;
|
||||
}
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["choice"] = options[0],
|
||||
["probabilities"] = probabilities,
|
||||
["confidence"] = firstProbability,
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject AnswerScore() => new() { ["score"] = 0.5 };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Wiki.Adapters.TypeSafe;
|
||||
using Wiki.Core.Ports;
|
||||
|
||||
namespace Wiki.Contracts.TypeSafe;
|
||||
|
||||
/// <summary>Runs the shared <see cref="JudgeContract"/> against the TypeSafe adapter over a fake server.</summary>
|
||||
public sealed class TypeSafeJudgeContractTests : JudgeContract
|
||||
{
|
||||
protected override Task<IJudge> CreateJudgeAsync()
|
||||
{
|
||||
var handler = new FakeTypeSafeHandler();
|
||||
var client = new HttpClient(handler);
|
||||
var options = new TypeSafeOptions
|
||||
{
|
||||
Url = "https://typesafe.test/v1/systemone",
|
||||
Model = "jev-latest",
|
||||
ApiKey = "test-key",
|
||||
};
|
||||
|
||||
IJudge judge = new TypeSafeJudge(client, options, delay: (_, _) => Task.CompletedTask);
|
||||
return Task.FromResult(judge);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Text.Json;
|
||||
using Wiki.Adapters.TypeSafe;
|
||||
using Wiki.Core.Model;
|
||||
|
||||
namespace Wiki.Contracts.TypeSafe;
|
||||
|
||||
public sealed class TypeSafeJudgeTests
|
||||
{
|
||||
private static TypeSafeJudge CreateJudge(FakeTypeSafeHandler handler, TypeSafeOptions? options = null)
|
||||
{
|
||||
var client = new HttpClient(handler);
|
||||
options ??= new TypeSafeOptions
|
||||
{
|
||||
Url = "https://typesafe.test/v1/systemone",
|
||||
Model = "jev-latest",
|
||||
ApiKey = "test-key",
|
||||
};
|
||||
|
||||
return new TypeSafeJudge(client, options, delay: (_, _) => Task.CompletedTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestBodyMatchesTheDocumentedShape()
|
||||
{
|
||||
var handler = new FakeTypeSafeHandler();
|
||||
var judge = CreateJudge(handler);
|
||||
|
||||
var state = new { question = "does the UDM answer DNS itself?" };
|
||||
var request = new JudgeRequest(state, new Dictionary<string, TypedQuestion>
|
||||
{
|
||||
["fact"] = new TypedQuestion.Noul("Do the docs say the UDM answers DNS itself?", "they do", "they do not"),
|
||||
["where"] = new TypedQuestion.Choice("Which doc says it?", new Dictionary<string, string> { ["a"] = "document a", ["NONE"] = "none" }),
|
||||
["how_sure"] = new TypedQuestion.Score("How directly is it stated?", "0 = not at all, 1 = verbatim"),
|
||||
});
|
||||
|
||||
await judge.JudgeAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(handler.LastRequestJson);
|
||||
using var document = JsonDocument.Parse(handler.LastRequestJson!);
|
||||
var root = document.RootElement;
|
||||
|
||||
Assert.Equal("jev-latest", root.GetProperty("model").GetString());
|
||||
Assert.Equal("does the UDM answer DNS itself?", root.GetProperty("state").GetProperty("question").GetString());
|
||||
|
||||
var fact = root.GetProperty("questions").GetProperty("fact");
|
||||
Assert.Equal("noul", fact.GetProperty("type").GetString());
|
||||
Assert.Equal("Do the docs say the UDM answers DNS itself?", fact.GetProperty("instructions").GetString());
|
||||
Assert.Equal("they do", fact.GetProperty("criteria").GetProperty("true").GetString());
|
||||
Assert.Equal("they do not", fact.GetProperty("criteria").GetProperty("false").GetString());
|
||||
|
||||
var where = root.GetProperty("questions").GetProperty("where");
|
||||
Assert.Equal("choice", where.GetProperty("type").GetString());
|
||||
Assert.Equal("document a", where.GetProperty("criteria").GetProperty("a").GetString());
|
||||
Assert.Equal("none", where.GetProperty("criteria").GetProperty("NONE").GetString());
|
||||
|
||||
var howSure = root.GetProperty("questions").GetProperty("how_sure");
|
||||
Assert.Equal("score", howSure.GetProperty("type").GetString());
|
||||
Assert.Equal("0 = not at all, 1 = verbatim", howSure.GetProperty("criteria").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendsABearerAuthorizationHeader()
|
||||
{
|
||||
var handler = new FakeTypeSafeHandler();
|
||||
var judge = CreateJudge(handler, new TypeSafeOptions
|
||||
{
|
||||
Url = "https://typesafe.test/v1/systemone",
|
||||
Model = "jev-latest",
|
||||
ApiKey = "super-secret",
|
||||
});
|
||||
|
||||
await judge.JudgeAsync(new JudgeRequest(new { }, new Dictionary<string, TypedQuestion>
|
||||
{
|
||||
["q"] = new TypedQuestion.Score("rate it", "0 = low, 1 = high"),
|
||||
}), CancellationToken.None);
|
||||
|
||||
Assert.NotNull(handler.LastAuthorization);
|
||||
Assert.Equal("Bearer", handler.LastAuthorization!.Scheme);
|
||||
Assert.Equal("super-secret", handler.LastAuthorization.Parameter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetriesOn429ThenSucceedsAndCountsOneRequest()
|
||||
{
|
||||
var handler = new FakeTypeSafeHandler { TooManyRequestsBeforeSuccess = 2 };
|
||||
var judge = CreateJudge(handler);
|
||||
|
||||
var result = await judge.JudgeAsync(new JudgeRequest(new { }, new Dictionary<string, TypedQuestion>
|
||||
{
|
||||
["q"] = new TypedQuestion.Score("rate it", "0 = low, 1 = high"),
|
||||
}), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.Usage.Requests);
|
||||
Assert.IsType<Judgement.Score>(result.Answers["q"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BadRequestThrowsWithTheResponseBody()
|
||||
{
|
||||
var handler = new FakeTypeSafeHandler { RespondBadRequest = true, BadRequestBody = "{\"error\":\"nope\"}" };
|
||||
var judge = CreateJudge(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => judge.JudgeAsync(new JudgeRequest(new { }, new Dictionary<string, TypedQuestion>
|
||||
{
|
||||
["q"] = new TypedQuestion.Score("rate it", "0 = low, 1 = high"),
|
||||
}), CancellationToken.None));
|
||||
|
||||
Assert.Contains("nope", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChoiceOverTheCapIsRejectedWithoutSendingARequest()
|
||||
{
|
||||
var handler = new FakeTypeSafeHandler();
|
||||
var judge = CreateJudge(handler);
|
||||
|
||||
var options = Enumerable.Range(0, 256).ToDictionary(i => $"o{i}", i => $"option {i}");
|
||||
var request = new JudgeRequest(new { }, new Dictionary<string, TypedQuestion>
|
||||
{
|
||||
["too_many"] = new TypedQuestion.Choice("pick", options),
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => judge.JudgeAsync(request, CancellationToken.None));
|
||||
Assert.Null(handler.LastRequestJson);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ public sealed class WikiHostFactory : WebApplicationFactory<Program>
|
||||
services.AddKeyedSingleton<IEmbeddingModel>("bge-m3", fake);
|
||||
services.AddSingleton<IEmbeddingModel>(fake);
|
||||
services.AddSingleton<IReadOnlyList<IEmbeddingModel>>([fake]);
|
||||
var judge = new FakeJudge();
|
||||
services.AddKeyedSingleton<IJudge>("typesafe", judge);
|
||||
services.RemoveAll<IJudge>();
|
||||
services.AddSingleton<IJudge>(judge);
|
||||
services.AddSingleton<IReadOnlyList<IJudge>>([judge]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -159,6 +164,39 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture<WikiHostFact
|
||||
Assert.Equal(HttpStatusCode.OK, (await Client.GetAsync("/api/v1/reports/2026-09-21-wiki-audit")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AskReturnsJudgementsWithParagraphText()
|
||||
{
|
||||
factory.Store.Files["wiki/udm-dns.md"] = "---\ntitle: UDM intercepts DNS\ntype: wiki\ntags: [dns]\n---\n\n# UDM intercepts DNS\n\nThe UDM answers every query.\n";
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync?force=true");
|
||||
req.Headers.Add("X-Api-Key", WikiHostFactory.ApiKey);
|
||||
await Client.SendAsync(req);
|
||||
|
||||
var bad = await Client.PostAsJsonAsync("/api/v1/ask", new { queries = Array.Empty<string>() });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
|
||||
var r = await Client.PostAsJsonAsync("/api/v1/ask", new
|
||||
{
|
||||
queries = new[] { "udm dns" },
|
||||
k = 3,
|
||||
questions = new Dictionary<string, object>
|
||||
{
|
||||
["fact"] = new { type = "noul", instructions = "does it", criteria = new { @true = "yes", @false = "no" } },
|
||||
["where"] = new { type = "choice", instructions = "which paragraph", criteria = "paragraphs" },
|
||||
},
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, r.StatusCode);
|
||||
var body = await r.Content.ReadFromJsonAsync<AskResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("fake-judge", body.Judge);
|
||||
Assert.InRange(body.Covered ?? -1, 0, 1);
|
||||
Assert.Equal(0.9, body.Answers["fact"].GetProperty("noul").GetDouble());
|
||||
var top = body.Answers["where"].GetProperty("top");
|
||||
Assert.True(top.GetArrayLength() >= 1);
|
||||
Assert.Contains("answers every query", top[0].GetProperty("text").GetString());
|
||||
}
|
||||
|
||||
private sealed record AskResponse(string Judge, double? Covered, Dictionary<string, System.Text.Json.JsonElement> Answers);
|
||||
private sealed record NoteRow(string Slug, string Title);
|
||||
private sealed record LinkRow(string? ToSlug, string? FromSlug, string Kind);
|
||||
private sealed record NoteBody(string Slug, string Body, List<LinkRow> Links, List<LinkRow> Backlinks);
|
||||
|
||||
@@ -44,6 +44,31 @@ public sealed class FakeStore : INoteStore
|
||||
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
|
||||
}
|
||||
|
||||
public sealed class FakeJudge : IJudge
|
||||
{
|
||||
public string ModelId => "fake-judge";
|
||||
public int? MaxChoiceOptions => 255;
|
||||
public string Name => "fake-judge";
|
||||
|
||||
public Task<JudgeResult> JudgeAsync(JudgeRequest request, CancellationToken ct)
|
||||
{
|
||||
var answers = new Dictionary<string, Judgement>();
|
||||
foreach (var (id, q) in request.Questions)
|
||||
{
|
||||
answers[id] = q switch
|
||||
{
|
||||
TypedQuestion.Noul => new Judgement.Noul(0.9),
|
||||
TypedQuestion.Choice c => new Judgement.Choice(c.Options.Keys.First(), c.Options.Keys.ToDictionary(k => k, k => 1.0 / c.Options.Count), 0.5),
|
||||
TypedQuestion.Score => new Judgement.Score(0.5),
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
}
|
||||
return Task.FromResult(new JudgeResult(answers, new JudgeUsage(1, 10, 1, TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
|
||||
}
|
||||
|
||||
public sealed class FakeEmbeddings(string modelId) : IEmbeddingModel
|
||||
{
|
||||
public string ModelId { get; } = modelId;
|
||||
|
||||
Reference in New Issue
Block a user