From 34cf91ebafa0ce74c885df404591c965e3e8ae6d Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 24 Sep 2026 13:57:17 +0200 Subject: [PATCH] ask: wiki_ask.py as a use case and POST /api/v1/ask; judges from configuration Rank every query, unite the top notes into one context under a per-document and a total budget, expand context choices over paragraph or document ids plus NONE, add the covered gate, judge context choices alone and the rest batched, attach the answering paragraphs' text. A missing TypeSafe key is a call-time error shown in status, not a startup failure. Co-Authored-By: Claude Fable 5.1 --- src/Wiki.Adapters.TypeSafe/TypeSafeJudge.cs | 3 + src/Wiki.Adapters.TypeSafe/TypeSafeOptions.cs | 5 - src/Wiki.Application/Ask/AskUseCase.cs | 253 ++++++++++++++++++ src/Wiki.Host/Api/ApiV1.cs | 1 + src/Wiki.Host/Api/AskEndpoints.cs | 122 +++++++++ src/Wiki.Host/Composition/WikiOptions.cs | 14 + src/Wiki.Host/Program.cs | 24 ++ src/Wiki.Host/appsettings.json | 6 + .../Wiki.Application.Tests/AskUseCaseTests.cs | 137 ++++++++++ test/Wiki.Host.Tests/EndpointTests.cs | 38 +++ test/Wiki.Host.Tests/Fakes.cs | 25 ++ 11 files changed, 623 insertions(+), 5 deletions(-) create mode 100644 src/Wiki.Application/Ask/AskUseCase.cs create mode 100644 src/Wiki.Host/Api/AskEndpoints.cs create mode 100644 test/Wiki.Application.Tests/AskUseCaseTests.cs diff --git a/src/Wiki.Adapters.TypeSafe/TypeSafeJudge.cs b/src/Wiki.Adapters.TypeSafe/TypeSafeJudge.cs index 0fc5518..017d58d 100644 --- a/src/Wiki.Adapters.TypeSafe/TypeSafeJudge.cs +++ b/src/Wiki.Adapters.TypeSafe/TypeSafeJudge.cs @@ -173,6 +173,9 @@ public sealed class TypeSafeJudge : IJudge 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) diff --git a/src/Wiki.Adapters.TypeSafe/TypeSafeOptions.cs b/src/Wiki.Adapters.TypeSafe/TypeSafeOptions.cs index bcf34cf..ca90527 100644 --- a/src/Wiki.Adapters.TypeSafe/TypeSafeOptions.cs +++ b/src/Wiki.Adapters.TypeSafe/TypeSafeOptions.cs @@ -39,11 +39,6 @@ public sealed class TypeSafeOptions throw new ArgumentException("Model must be set.", nameof(Model)); } - if (string.IsNullOrWhiteSpace(ApiKey)) - { - throw new ArgumentException("ApiKey must be set.", nameof(ApiKey)); - } - if (MaxRetries < 0) { throw new ArgumentException("MaxRetries must not be negative.", nameof(MaxRetries)); diff --git a/src/Wiki.Application/Ask/AskUseCase.cs b/src/Wiki.Application/Ask/AskUseCase.cs new file mode 100644 index 0000000..7de3c36 --- /dev/null +++ b/src/Wiki.Application/Ask/AskUseCase.cs @@ -0,0 +1,253 @@ +using System.Diagnostics; +using Wiki.Application.Retrieval; +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Application.Ask; + +/// A caller's question, before expansion. Context choices are expanded over the context's paragraph or document ids. +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 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 Queries, IReadOnlyDictionary? Questions, AskOptions Options); + +public sealed record ContextDocument(string Slug, string Title, IReadOnlyList Paragraphs, bool Truncated, float RankScore); + +public sealed record RetrievalMeta(IReadOnlyList DroppedForBudget, int Chars, int ParagraphCount, IReadOnlyDictionary RankScoreBySlug); + +/// A resolved context hit: a paragraph (with its text) or a document (with its title). +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 Probabilities, double Confidence) : AskAnswer; + public sealed record Context(IReadOnlyList Top, double NoneP, double Confidence) : AskAnswer; + public sealed record Score(double Value) : AskAnswer; +} + +public sealed record AskResult( + IReadOnlyList Queries, + IReadOnlyList Context, + RetrievalMeta Retrieval, + IReadOnlyDictionary Answers, + double? Covered, + JudgeUsage Usage, + string JudgeModel, + TimeSpan Elapsed); + +/// +/// 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. +/// +public sealed class AskUseCase(RankUseCase rank, IDocumentCatalog catalog, IChunker chunker) +{ + public const string None = "NONE"; + public const string CoveredId = "_covered"; + + public async Task 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 Context, RetrievalMeta Meta)> BuildContextAsync( + IReadOnlyList queries, IEmbeddingModel model, AskOptions options, CancellationToken ct) + { + var order = new Dictionary(); + 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(); + var dropped = new List(); + 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 Paragraphs, bool Truncated) Cap(IReadOnlyList paragraphs, int docChars) + { + var kept = new List(); + 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 DefaultQuestions(IReadOnlyList queries) + { + var qs = new Dictionary(); + 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 Questions, IReadOnlyDictionary Scopes) Expand( + IReadOnlyDictionary questions, IReadOnlyList context, IReadOnlyList 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(); + var scopes = new Dictionary(); + 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 Answers, JudgeUsage Usage)> JudgeInParallelAsync( + IJudge judge, IReadOnlyList context, IReadOnlyDictionary questions, + IReadOnlyDictionary 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>(); + foreach (var (id, q) in questions.Where(kv => scopes.ContainsKey(kv.Key))) + groups.Add(new Dictionary { [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(); + 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 Resolve( + IReadOnlyDictionary answers, IReadOnlyDictionary scopes, IReadOnlyList 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(); + 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; + } +} diff --git a/src/Wiki.Host/Api/ApiV1.cs b/src/Wiki.Host/Api/ApiV1.cs index f7e7dff..249d1f6 100644 --- a/src/Wiki.Host/Api/ApiV1.cs +++ b/src/Wiki.Host/Api/ApiV1.cs @@ -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(); diff --git a/src/Wiki.Host/Api/AskEndpoints.cs b/src/Wiki.Host/Api/AskEndpoints.cs new file mode 100644 index 0000000..f27d757 --- /dev/null +++ b/src/Wiki.Host/Api/AskEndpoints.cs @@ -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; + +/// 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. +public sealed record AskQuestionDto(string Type, string Instructions, JsonElement? Criteria); + +public sealed record AskRequestDto( + [property: JsonPropertyName("queries")] IReadOnlyList? Queries, + [property: JsonPropertyName("questions")] IReadOnlyDictionary? 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(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(body.Judge); + if (judge is null) return Results.BadRequest(new { error = $"unknown judge '{body.Judge}'" }); + } + + IReadOnlyDictionary? 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(); + 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}'"); + } + } +} diff --git a/src/Wiki.Host/Composition/WikiOptions.cs b/src/Wiki.Host/Composition/WikiOptions.cs index 2b39673..92c95f5 100644 --- a/src/Wiki.Host/Composition/WikiOptions.cs +++ b/src/Wiki.Host/Composition/WikiOptions.cs @@ -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 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"; diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index 1ec622f..85f0796 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -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>(sp => wiki.Embeddings.Models.Keys.Select(sp.GetRequiredKeyedService).ToList()); builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService(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>(sp => + wiki.Judges.Models.Keys.Select(sp.GetRequiredKeyedService).ToList()); +builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService(wiki.Judges.Active)); // One adapter may serve several ports (Gitea is note store and fact sink); list it once. builder.Services.AddSingleton>(sp => { @@ -56,6 +77,7 @@ builder.Services.AddSingleton>(sp => sp.GetRequiredService(), sp.GetRequiredService(), .. sp.GetRequiredService>(), + .. sp.GetRequiredService>(), ]; return all.Distinct().ToList(); }); @@ -66,6 +88,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/src/Wiki.Host/appsettings.json b/src/Wiki.Host/appsettings.json index 0a93de3..0cd765a 100644 --- a/src/Wiki.Host/appsettings.json +++ b/src/Wiki.Host/appsettings.json @@ -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 } }, diff --git a/test/Wiki.Application.Tests/AskUseCaseTests.cs b/test/Wiki.Application.Tests/AskUseCaseTests.cs new file mode 100644 index 0000000..fbb7266 --- /dev/null +++ b/test/Wiki.Application.Tests/AskUseCaseTests.cs @@ -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; + +/// A judge that records requests and answers by simple rules, so the use case's wiring is testable. +public sealed class FakeJudge : IJudge +{ + public List Requests { get; } = []; + public string ModelId => "fake-judge"; + public int? MaxChoiceOptions { get; init; } = 255; + public string Name => "fake-judge"; + + public Task JudgeAsync(JudgeRequest request, CancellationToken ct) + { + Requests.Add(request); + var answers = new Dictionary(); + 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(); + 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 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.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(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 + { + ["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(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(result.Answers["src"]); + Assert.Equal("udm-dns", src.Top[0].Slug); + Assert.Null(src.Top[0].Text); + Assert.IsType(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(() => UseCase.AskAsync(request, _model, small, CancellationToken.None)); + } +} diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs index a148f8c..23cb173 100644 --- a/test/Wiki.Host.Tests/EndpointTests.cs +++ b/test/Wiki.Host.Tests/EndpointTests.cs @@ -38,6 +38,11 @@ public sealed class WikiHostFactory : WebApplicationFactory services.AddKeyedSingleton("bge-m3", fake); services.AddSingleton(fake); services.AddSingleton>([fake]); + var judge = new FakeJudge(); + services.AddKeyedSingleton("typesafe", judge); + services.RemoveAll(); + services.AddSingleton(judge); + services.AddSingleton>([judge]); }); } } @@ -159,6 +164,39 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture() }); + Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); + + var r = await Client.PostAsJsonAsync("/api/v1/ask", new + { + queries = new[] { "udm dns" }, + k = 3, + questions = new Dictionary + { + ["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(); + 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 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 Links, List Backlinks); diff --git a/test/Wiki.Host.Tests/Fakes.cs b/test/Wiki.Host.Tests/Fakes.cs index cf29fcf..cfd8b7e 100644 --- a/test/Wiki.Host.Tests/Fakes.cs +++ b/test/Wiki.Host.Tests/Fakes.cs @@ -44,6 +44,31 @@ public sealed class FakeStore : INoteStore public Task 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 JudgeAsync(JudgeRequest request, CancellationToken ct) + { + var answers = new Dictionary(); + 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 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;