POST /api/v1/questions runs ask (rank + judge), selects the paragraphs that carry probability mass, and streams one answer-model call over them. Below the coverage gate no model is called and the response names the gap. With save: true (signed-in user or API key) the answer lands in output/ as a synth-shaped document with sources, confidence and the answering model. The Answers page streams the same use case and lists saved answers. Anthropic is the first IAnswerModel adapter; the answer model is chosen per request or from Wiki:AnswerModels:Active. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
103 lines
5.2 KiB
C#
103 lines
5.2 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Wiki.Application.Ask;
|
|
using Wiki.Application.Questions;
|
|
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>An answer model that echoes the evidence slugs, so the wiring is visible in the text.</summary>
|
|
public sealed class FakeAnswerModel : IAnswerModel
|
|
{
|
|
public List<AnswerRequest> Requests { get; } = [];
|
|
public string ModelId => "fake-answer";
|
|
public string Name => "fake-answer";
|
|
|
|
public async IAsyncEnumerable<AnswerChunk> AnswerAsync(AnswerRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
|
{
|
|
Requests.Add(request);
|
|
yield return new AnswerChunk.Text("Yes. ");
|
|
yield return new AnswerChunk.Text(string.Join(" ", request.Evidence.Select(e => $"[[{e.Paragraph.Slug}]]")));
|
|
await Task.CompletedTask;
|
|
yield return new AnswerChunk.Done(ModelId, 100, 12, TimeSpan.FromMilliseconds(3));
|
|
}
|
|
|
|
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
|
|
}
|
|
|
|
public class QuestionUseCaseTests
|
|
{
|
|
private readonly FakeStore _store = new();
|
|
private readonly FakeCatalog _catalog = new();
|
|
private readonly FakeIndex _index = new();
|
|
private readonly FakeEmbeddings _model = new("m1");
|
|
private readonly FakeAnswerModel _answer = new();
|
|
|
|
private QuestionUseCase UseCase => new(new AskUseCase(new RankUseCase(_index, _catalog), _catalog, new ParagraphChunker()));
|
|
|
|
private async Task<Document> 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";
|
|
_store.Files["wiki/ceph-osd.md"] = "---\ntitle: Ceph OSD\ntype: wiki\n---\n\n# Ceph OSD\n\nOSD fsid.\n";
|
|
var sync = new SyncUseCase(_store, _catalog, _index, [_model], TimeProvider.System, NullLogger<SyncUseCase>.Instance);
|
|
await sync.RunAsync(false, CancellationToken.None);
|
|
return (await _catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None))!;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StreamsEvidenceThenTextThenDone()
|
|
{
|
|
var udm = await SeedAsync();
|
|
var events = new List<QuestionEvent>();
|
|
await foreach (var e in UseCase.AnswerAsync(EmbeddingText.For(udm), new QuestionOptions(K: 2), _model, new FakeJudge(), _answer, CancellationToken.None))
|
|
events.Add(e);
|
|
|
|
var evidence = Assert.IsType<QuestionEvent.Evidence>(events[0]);
|
|
Assert.Equal("udm-dns#p1", evidence.Paragraphs[0].Paragraph.Id);
|
|
Assert.InRange(evidence.Covered, 0.79, 0.81);
|
|
var text = string.Concat(events.OfType<QuestionEvent.Text>().Select(t => t.Delta));
|
|
Assert.Contains("[[udm-dns]]", text);
|
|
var done = Assert.IsType<QuestionEvent.Done>(events[^1]);
|
|
Assert.Equal("fake-answer", done.AnswerModel);
|
|
var request = Assert.Single(_answer.Requests);
|
|
Assert.Equal(2, request.Evidence.Count); // both paragraphs carry probability mass in the fake judge
|
|
Assert.Equal("udm-dns#p1", request.Evidence[0].Paragraph.Id);
|
|
Assert.True(request.Judgements.ContainsKey("answered_1"));
|
|
Assert.True(request.Judgements.ContainsKey(AskUseCase.CoveredId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BelowTheGateNoAnswerModelIsCalled()
|
|
{
|
|
var udm = await SeedAsync();
|
|
var events = new List<QuestionEvent>();
|
|
await foreach (var e in UseCase.AnswerAsync(EmbeddingText.For(udm), new QuestionOptions(K: 2, CoveredThreshold: 0.95), _model, new FakeJudge(), _answer, CancellationToken.None))
|
|
events.Add(e);
|
|
var gap = Assert.IsType<QuestionEvent.Gap>(Assert.Single(events));
|
|
Assert.Contains("does not cover", gap.Message);
|
|
Assert.Empty(_answer.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SavedAnswerIsAnOutputDocumentInTheSynthShape()
|
|
{
|
|
await SeedAsync();
|
|
var evidence = new List<EvidenceParagraph> { new(new Paragraph("udm-dns#p1", "udm-dns", "UDM", "text"), "UDM intercepts DNS", 0.8) };
|
|
var save = new SaveAnswerUseCase(_store, new FakeTimeProvider(new DateTimeOffset(2026, 9, 24, 12, 0, 0, TimeSpan.Zero)));
|
|
var written = await save.SaveAsync("Does the UDM intercept DNS?", "Yes [[udm-dns]].", evidence, 0.88, "claude-sonnet", CancellationToken.None);
|
|
Assert.Equal("output/2026-09-24-does-the-udm-intercept-dns.md", written.Path);
|
|
var text = _store.Files[written.Path];
|
|
Assert.Contains("type: output\n", text);
|
|
Assert.Contains("question: \"Does the UDM intercept DNS?\"", text.Replace("question: Does the UDM intercept DNS?", "question: \"Does the UDM intercept DNS?\""));
|
|
Assert.Contains("sources: [udm-dns]", text);
|
|
Assert.Contains("confidence: high", text);
|
|
Assert.Contains("- [[udm-dns]]", text);
|
|
var second = await save.SaveAsync("Does the UDM intercept DNS?", "Yes again.", evidence, 0.5, "claude-sonnet", CancellationToken.None);
|
|
Assert.Equal("output/2026-09-24-does-the-udm-intercept-dns-2.md", second.Path);
|
|
}
|
|
}
|