diff --git a/README.md b/README.md index 28653c4..ae9a51d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ test/Wiki.Contracts one abstract suite per port; every adapter passes its sui Adapters are selected in `appsettings.json` under `Wiki:`; secrets come from environment variables (`Wiki__NoteStore__Gitea__Token`, `Wiki__Webhook__Secret`, `Auth__ApiKeys__`, -`Wiki__Judges__Models____ApiKey`, `Wiki__AnswerModels__Models____ApiKey`). +`Wiki__Judges__Models____ApiKey`, `Wiki__AnswerModels__Models____ApiKey`). Each +answer-model entry also provides the fact drafter of the same name (`/facts`). ## Run diff --git a/src/Wiki.Adapters.Anthropic/AnthropicAnswerModel.cs b/src/Wiki.Adapters.Anthropic/AnthropicAnswerModel.cs index a3b7836..2587b96 100644 --- a/src/Wiki.Adapters.Anthropic/AnthropicAnswerModel.cs +++ b/src/Wiki.Adapters.Anthropic/AnthropicAnswerModel.cs @@ -1,8 +1,3 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; using Wiki.Core.Model; using Wiki.Core.Ports; @@ -10,216 +5,28 @@ namespace Wiki.Adapters.Anthropic; /// /// Answers questions via Anthropic's Messages API, streamed over server-sent events. One HTTP -/// request per call, retried on 429/529 with the server's Retry-After -/// (or exponential backoff), never retried for anything else. +/// request per call; see for retries. /// public sealed class AnthropicAnswerModel : IAnswerModel { - private const string AnthropicVersion = "2023-06-01"; - - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - private readonly HttpClient _http; - private readonly AnthropicOptions _options; - private readonly Func _delay; + private readonly MessagesTransport _transport; public AnthropicAnswerModel(HttpClient http, AnthropicOptions options, Func? delay = null) { - _http = http; - _options = options; - _delay = delay ?? Task.Delay; + _transport = new MessagesTransport(http, options, delay); + ModelId = options.ModelId; } - public string ModelId => _options.ModelId; + public string ModelId { get; } public string Name => $"anthropic:{ModelId}"; - public async IAsyncEnumerable AnswerAsync(AnswerRequest request, [EnumeratorCancellation] CancellationToken ct) + public IAsyncEnumerable AnswerAsync(AnswerRequest request, CancellationToken ct) { ArgumentNullException.ThrowIfNull(request); - ct.ThrowIfCancellationRequested(); - var (system, user) = AnswerPrompt.Build(request); - var body = BuildRequestBody(system, user); - - var stopwatch = Stopwatch.StartNew(); - using var response = await PostWithRetryAsync(body, ct).ConfigureAwait(false); - var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) - using (var reader = new StreamReader(stream, Encoding.UTF8)) - { - long inputTokens = 0; - long outputTokens = 0; - string? eventName = null; - - string? line; - while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) is not null) - { - if (line.Length == 0) - { - eventName = null; - continue; - } - - if (line.StartsWith("event: ", StringComparison.Ordinal)) - { - eventName = line["event: ".Length..]; - continue; - } - - if (!line.StartsWith("data: ", StringComparison.Ordinal)) - { - continue; - } - - var data = line["data: ".Length..]; - using var payload = JsonDocument.Parse(data); - var root = payload.RootElement; - - switch (eventName) - { - case "message_start": - inputTokens = root.GetProperty("message").GetProperty("usage").GetProperty("input_tokens").GetInt64(); - break; - - case "content_block_delta": - var delta = root.GetProperty("delta"); - if (delta.GetProperty("type").GetString() == "text_delta") - { - yield return new AnswerChunk.Text(delta.GetProperty("text").GetString() ?? ""); - } - - break; - - case "message_delta": - outputTokens = root.GetProperty("usage").GetProperty("output_tokens").GetInt64(); - break; - - case "error": - throw new HttpRequestException($"anthropic: {root.GetProperty("error").GetProperty("message").GetString()}"); - - // message_stop, ping, content_block_start, content_block_stop: nothing to record. - default: - break; - } - } - - stopwatch.Stop(); - yield return new AnswerChunk.Done(ModelId, inputTokens, outputTokens, stopwatch.Elapsed); - } + return _transport.StreamAsync(system, user, ModelId, ct); } - public async Task CheckAsync(CancellationToken ct) - { - var stopwatch = Stopwatch.StartNew(); - try - { - if (string.IsNullOrWhiteSpace(_options.ApiKey)) - { - throw new InvalidOperationException("Anthropic API key is not configured."); - } - - using var requestMessage = new HttpRequestMessage(HttpMethod.Get, $"{_options.BaseUrl}/v1/models"); - requestMessage.Headers.Add("x-api-key", _options.ApiKey); - requestMessage.Headers.Add("anthropic-version", AnthropicVersion); - - using var response = await _http.SendAsync(requestMessage, ct).ConfigureAwait(false); - var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - - if (!response.IsSuccessStatusCode) - { - var truncated = responseBody.Length > 500 ? responseBody[..500] : responseBody; - return new AdapterHealth(Name, false, $"HTTP {(int)response.StatusCode}: {truncated}", stopwatch.Elapsed, DateTimeOffset.UtcNow); - } - - using var document = JsonDocument.Parse(responseBody); - var count = document.RootElement.GetProperty("data").GetArrayLength(); - return new AdapterHealth(Name, true, $"{count} models", 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(string system, string user) => new() - { - ["model"] = _options.Model, - ["max_tokens"] = _options.MaxTokens, - ["system"] = system, - ["messages"] = new JsonArray - { - new JsonObject - { - ["role"] = "user", - ["content"] = user, - }, - }, - ["stream"] = true, - }; - - private async Task PostWithRetryAsync(JsonObject body, CancellationToken ct) - { - // 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("Anthropic API key is not configured."); - } - - var json = body.ToJsonString(JsonOptions); - var delay = TimeSpan.FromSeconds(1); - - for (var attempt = 0; ; attempt++) - { - using var requestMessage = new HttpRequestMessage(HttpMethod.Post, $"{_options.BaseUrl}/v1/messages") - { - Content = new StringContent(json, Encoding.UTF8, "application/json"), - }; - requestMessage.Headers.Add("x-api-key", _options.ApiKey); - requestMessage.Headers.Add("anthropic-version", AnthropicVersion); - - var response = await _http.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, 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($"anthropic: 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; - } + public Task CheckAsync(CancellationToken ct) => _transport.CheckAsync(Name, ct); } diff --git a/src/Wiki.Adapters.Anthropic/AnthropicFactDrafter.cs b/src/Wiki.Adapters.Anthropic/AnthropicFactDrafter.cs new file mode 100644 index 0000000..2111460 --- /dev/null +++ b/src/Wiki.Adapters.Anthropic/AnthropicFactDrafter.cs @@ -0,0 +1,38 @@ +using System.Text; +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Adapters.Anthropic; + +/// Turns a statement into raw-source fields with one Messages API call. Same transport and options as the answer model. +public sealed class AnthropicFactDrafter : IFactDrafter +{ + private readonly MessagesTransport _transport; + + public AnthropicFactDrafter(HttpClient http, AnthropicOptions options, Func? delay = null) + { + _transport = new MessagesTransport(http, options, delay); + ModelId = options.ModelId; + } + + public string ModelId { get; } + + public string Name => $"anthropic-facts:{ModelId}"; + + public async Task DraftAsync(FactStatement statement, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(statement); + if (string.IsNullOrWhiteSpace(statement.Text)) + throw new ArgumentException("the statement is empty", nameof(statement)); + + var (system, user) = FactDraftPrompt.Build(statement); + var text = new StringBuilder(); + await foreach (var chunk in _transport.StreamAsync(system, user, ModelId, ct).ConfigureAwait(false)) + { + if (chunk is AnswerChunk.Text t) text.Append(t.Delta); + } + return FactDraftPrompt.Parse(text.ToString()); + } + + public Task CheckAsync(CancellationToken ct) => _transport.CheckAsync(Name, ct); +} diff --git a/src/Wiki.Adapters.Anthropic/FactDraftPrompt.cs b/src/Wiki.Adapters.Anthropic/FactDraftPrompt.cs new file mode 100644 index 0000000..01992ac --- /dev/null +++ b/src/Wiki.Adapters.Anthropic/FactDraftPrompt.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Wiki.Core.Model; + +namespace Wiki.Adapters.Anthropic; + +/// Builds the prompt that turns a statement into raw-source fields, and parses the model's JSON. Pure. +public static class FactDraftPrompt +{ + private const string SystemPrompt = + "You turn one statement a person makes about their private homelab into the fields of a raw fact record for a wiki. " + + "Reply with a single JSON object and nothing else: {\"title\": string, \"content\": string, \"tags\": [string]}. " + + "title: a short noun phrase in the language of the statement, no trailing period. " + + "content: the statement restated faithfully as complete sentences in the language of the statement; keep every detail and every exception, add nothing that was not said, at least 40 characters. " + + "tags: one to five lowercase ASCII slugs (a-z, 0-9, hyphen) naming the topics, English. " + + "No markdown, no code fences, no commentary."; + + public static (string System, string User) Build(FactStatement statement) + { + ArgumentNullException.ThrowIfNull(statement); + var user = new StringBuilder(); + user.Append("Stated by ").Append(statement.By.Name) + .Append(" on ").Append(statement.StatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(":\n\n"); + user.Append(statement.Text); + return (SystemPrompt, user.ToString()); + } + + /// The draft in the model's reply. Tolerates text around the JSON object; rejects anything without title and content. + public static FactDraft Parse(string text) + { + ArgumentNullException.ThrowIfNull(text); + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + if (start < 0 || end <= start) + throw new InvalidOperationException("the drafter returned no JSON object"); + + try + { + using var document = JsonDocument.Parse(text[start..(end + 1)]); + var root = document.RootElement; + var title = root.TryGetProperty("title", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString()?.Trim() : null; + var content = root.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString()?.Trim() : null; + var tags = root.TryGetProperty("tags", out var g) && g.ValueKind == JsonValueKind.Array + ? g.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => Slug(x.GetString() ?? "")).Where(s => s.Length > 0).Distinct().Take(5).ToList() + : []; + if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(content)) + throw new InvalidOperationException("the drafter returned no title or content"); + return new FactDraft(title, content, tags); + } + catch (JsonException e) + { + throw new InvalidOperationException($"the drafter returned malformed JSON: {e.Message}", e); + } + } + + private static string Slug(string tag) + { + var sb = new StringBuilder(tag.Length); + var dash = false; + foreach (var ch in tag.ToLowerInvariant()) + { + if (ch is (>= 'a' and <= 'z') or (>= '0' and <= '9')) { sb.Append(ch); dash = false; } + else if (!dash) { sb.Append('-'); dash = true; } + } + return sb.ToString().Trim('-'); + } +} diff --git a/src/Wiki.Adapters.Anthropic/MessagesTransport.cs b/src/Wiki.Adapters.Anthropic/MessagesTransport.cs new file mode 100644 index 0000000..1d28527 --- /dev/null +++ b/src/Wiki.Adapters.Anthropic/MessagesTransport.cs @@ -0,0 +1,216 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Wiki.Core.Model; + +namespace Wiki.Adapters.Anthropic; + +/// +/// One streamed Messages API call, shared by every port the adapter serves. Retried on 429/529 +/// with the server's Retry-After (or exponential backoff), never retried for anything else. +/// +internal sealed class MessagesTransport +{ + private const string AnthropicVersion = "2023-06-01"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly HttpClient _http; + private readonly AnthropicOptions _options; + private readonly Func _delay; + + public MessagesTransport(HttpClient http, AnthropicOptions options, Func? delay) + { + _http = http; + _options = options; + _delay = delay ?? Task.Delay; + } + + public async IAsyncEnumerable StreamAsync(string system, string user, string modelId, [EnumeratorCancellation] CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + var body = BuildRequestBody(system, user); + var stopwatch = Stopwatch.StartNew(); + using var response = await PostWithRetryAsync(body, ct).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + using (var reader = new StreamReader(stream, Encoding.UTF8)) + { + long inputTokens = 0; + long outputTokens = 0; + string? eventName = null; + + string? line; + while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) is not null) + { + if (line.Length == 0) + { + eventName = null; + continue; + } + + if (line.StartsWith("event: ", StringComparison.Ordinal)) + { + eventName = line["event: ".Length..]; + continue; + } + + if (!line.StartsWith("data: ", StringComparison.Ordinal)) + { + continue; + } + + var data = line["data: ".Length..]; + using var payload = JsonDocument.Parse(data); + var root = payload.RootElement; + + switch (eventName) + { + case "message_start": + inputTokens = root.GetProperty("message").GetProperty("usage").GetProperty("input_tokens").GetInt64(); + break; + + case "content_block_delta": + var delta = root.GetProperty("delta"); + if (delta.GetProperty("type").GetString() == "text_delta") + { + yield return new AnswerChunk.Text(delta.GetProperty("text").GetString() ?? ""); + } + + break; + + case "message_delta": + outputTokens = root.GetProperty("usage").GetProperty("output_tokens").GetInt64(); + break; + + case "error": + throw new HttpRequestException($"anthropic: {root.GetProperty("error").GetProperty("message").GetString()}"); + + // message_stop, ping, content_block_start, content_block_stop: nothing to record. + default: + break; + } + } + + stopwatch.Stop(); + yield return new AnswerChunk.Done(modelId, inputTokens, outputTokens, stopwatch.Elapsed); + } + } + + public async Task CheckAsync(string name, CancellationToken ct) + { + var stopwatch = Stopwatch.StartNew(); + try + { + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + { + throw new InvalidOperationException("Anthropic API key is not configured."); + } + + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, $"{_options.BaseUrl}/v1/models"); + requestMessage.Headers.Add("x-api-key", _options.ApiKey); + requestMessage.Headers.Add("anthropic-version", AnthropicVersion); + + using var response = await _http.SendAsync(requestMessage, ct).ConfigureAwait(false); + var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var truncated = responseBody.Length > 500 ? responseBody[..500] : responseBody; + return new AdapterHealth(name, false, $"HTTP {(int)response.StatusCode}: {truncated}", stopwatch.Elapsed, DateTimeOffset.UtcNow); + } + + using var document = JsonDocument.Parse(responseBody); + var count = document.RootElement.GetProperty("data").GetArrayLength(); + return new AdapterHealth(name, true, $"{count} models", 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(string system, string user) => new() + { + ["model"] = _options.Model, + ["max_tokens"] = _options.MaxTokens, + ["system"] = system, + ["messages"] = new JsonArray + { + new JsonObject + { + ["role"] = "user", + ["content"] = user, + }, + }, + ["stream"] = true, + }; + + private async Task PostWithRetryAsync(JsonObject body, CancellationToken ct) + { + // 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("Anthropic API key is not configured."); + } + + var json = body.ToJsonString(JsonOptions); + var delay = TimeSpan.FromSeconds(1); + + for (var attempt = 0; ; attempt++) + { + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, $"{_options.BaseUrl}/v1/messages") + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + requestMessage.Headers.Add("x-api-key", _options.ApiKey); + requestMessage.Headers.Add("anthropic-version", AnthropicVersion); + + var response = await _http.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, 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($"anthropic: 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; + } +} diff --git a/src/Wiki.Adapters.Anthropic/ServiceCollectionExtensions.cs b/src/Wiki.Adapters.Anthropic/ServiceCollectionExtensions.cs index 17da788..db62318 100644 --- a/src/Wiki.Adapters.Anthropic/ServiceCollectionExtensions.cs +++ b/src/Wiki.Adapters.Anthropic/ServiceCollectionExtensions.cs @@ -39,4 +39,38 @@ public static class ServiceCollectionExtensions return services; } + + /// + /// Registers an backed by Anthropic's Messages API, keyed by + /// . Usually registered alongside the answer model of the same name. + /// + public static IServiceCollection AddAnthropicFactDrafter( + this IServiceCollection services, + string name, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(configure); + + var options = new AnthropicOptions(); + configure(options); + options.Validate(); + + var httpClientName = $"anthropic-facts:{name}"; + + services.AddHttpClient(httpClientName, client => + { + client.Timeout = options.Timeout; + }); + + services.AddKeyedSingleton(name, (sp, _) => + { + var httpClientFactory = sp.GetRequiredService(); + var client = httpClientFactory.CreateClient(httpClientName); + return new AnthropicFactDrafter(client, options); + }); + + return services; + } } diff --git a/src/Wiki.Application/Facts/RecordStatementUseCase.cs b/src/Wiki.Application/Facts/RecordStatementUseCase.cs new file mode 100644 index 0000000..97de53c --- /dev/null +++ b/src/Wiki.Application/Facts/RecordStatementUseCase.cs @@ -0,0 +1,41 @@ +using System.Globalization; +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Application.Facts; + +public sealed record RecordedStatement(FactDraft Draft, RawSource Fact, FactReceipt Receipt); + +/// +/// A person states something; a drafter normalises it into title, content and tags; the raw +/// source that lands carries the drafter's restatement and the statement verbatim, with the +/// person as source. Provenance is the person's word, and the note says so. +/// +public sealed class RecordStatementUseCase(DepositFactUseCase deposit, TimeProvider clock) +{ + public async Task RecordAsync(string statement, Identity by, IFactDrafter drafter, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(statement); + ArgumentNullException.ThrowIfNull(by); + ArgumentNullException.ThrowIfNull(drafter); + + var now = clock.GetUtcNow(); + var text = statement.Trim(); + var draft = await drafter.DraftAsync(new FactStatement(text, by, now), ct); + var fact = new RawSource( + draft.Title, + $"stated by {by.Name} on {now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)} via the wiki", + Body(draft.Content, text), + now, + draft.Tags); + var errors = fact.Validate(); + if (errors.Count > 0) + throw new InvalidOperationException($"the draft is not a valid raw source: {string.Join("; ", errors)}"); + + var receipt = await deposit.DepositAsync(fact, by, ct); + return new RecordedStatement(draft, fact, receipt); + } + + public static string Body(string content, string statement) => + content.TrimEnd() + "\n\nStatement, verbatim:\n\n> " + statement.Trim().Replace("\n", "\n> ", StringComparison.Ordinal) + "\n"; +} diff --git a/src/Wiki.Core/Model/FactDraft.cs b/src/Wiki.Core/Model/FactDraft.cs new file mode 100644 index 0000000..a4cd108 --- /dev/null +++ b/src/Wiki.Core/Model/FactDraft.cs @@ -0,0 +1,7 @@ +namespace Wiki.Core.Model; + +/// What a person said, by whom and when. The drafter sees nothing else. +public sealed record FactStatement(string Text, Identity By, DateTimeOffset StatedAt); + +/// A statement normalised into the fields of a raw source. Source and provenance are the application's to add. +public sealed record FactDraft(string Title, string Content, IReadOnlyList Tags); diff --git a/src/Wiki.Core/Ports/Ports.cs b/src/Wiki.Core/Ports/Ports.cs index 9378bcd..f282172 100644 --- a/src/Wiki.Core/Ports/Ports.cs +++ b/src/Wiki.Core/Ports/Ports.cs @@ -88,3 +88,10 @@ public interface IAnswerModel : IAdapterHealth string ModelId { get; } IAsyncEnumerable AnswerAsync(AnswerRequest request, CancellationToken ct); } + +/// A person's statement into the fields of a raw source. Sees only the statement. +public interface IFactDrafter : IAdapterHealth +{ + string ModelId { get; } + Task DraftAsync(FactStatement statement, CancellationToken ct); +} diff --git a/src/Wiki.Host/Components/Pages/Facts.razor b/src/Wiki.Host/Components/Pages/Facts.razor index 981e1c1..c660005 100644 --- a/src/Wiki.Host/Components/Pages/Facts.razor +++ b/src/Wiki.Host/Components/Pages/Facts.razor @@ -2,51 +2,31 @@ @rendermode InteractiveServer @using Microsoft.AspNetCore.Components.Authorization @using Wiki.Application.Facts -@inject DepositFactUseCase Deposit +@inject RecordStatementUseCase Record +@inject IFactDrafter Drafter @inject NoteQueries Notes -@inject TimeProvider Clock @inject ISnackbar Snackbar Facts Facts - A fact is one verified observation with its provenance. It lands verbatim in raw/ under your name and the nightly distill turns it into wiki notes. Raw holds observations only, never hypotheses. + Say what you observed, in one sentence or a few. @Drafter.ModelId turns it into a raw source under your name, with your words kept verbatim, and the nightly distill turns that into wiki notes. Observations only, never hypotheses. @if (_user is null) { - Not signed in. Facts are attributed to the person depositing them, so this page needs the Authentik sign-in at wiki.renner.uno. + Not signed in. Facts are attributed to the person stating them, so this page needs the Authentik sign-in at wiki.renner.uno. } else { - - - - - - - - - - - - - - - - - - - - - +
+ + captured_by: @_user - Deposit + Record - + } @if (_running) @@ -57,11 +37,21 @@ else { @_error } -@if (_receipt is not null) +@if (_recorded is not null) { - - Stored as @_receipt.Path (commit @_receipt.Commit[..Math.Min(8, _receipt.Commit.Length)]). - + + + @_recorded.Draft.Title + @_recorded.Receipt.Path + + @_recorded.Draft.Content + + @foreach (var t in _recorded.Draft.Tags) + { + @t + } + + } Raw sources awaiting distillation @@ -95,22 +85,15 @@ else @code { [CascadingParameter] private Task? AuthState { get; set; } - private MudForm? _form; - private bool _valid; private bool _running; private string? _user; - private string? _title; - private string? _source; - private string? _tags; - private string? _content; - private DateTime? _observedDate; + private string? _statement; private string? _error; - private FactReceipt? _receipt; + private RecordedStatement? _recorded; private IReadOnlyList? _raw; protected override async Task OnInitializedAsync() { - _observedDate = Clock.GetUtcNow().Date; if (AuthState is not null) { var state = await AuthState; @@ -121,35 +104,22 @@ else private async Task LoadRawAsync() => _raw = await Notes.RawQueueAsync(CancellationToken.None); - private async Task DepositAsync() + private async Task RecordAsync() { - if (_running || _user is null) return; + if (_running || _user is null || string.IsNullOrWhiteSpace(_statement)) return; _running = true; _error = null; - _receipt = null; + _recorded = null; try { - var observed = _observedDate is { } d ? new DateTimeOffset(DateTime.SpecifyKind(d, DateTimeKind.Utc)) : Clock.GetUtcNow(); - var fact = new RawSource( - _title?.Trim() ?? string.Empty, - _source?.Trim() ?? string.Empty, - _content ?? string.Empty, - observed, - (_tags ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); - var errors = fact.Validate(); - if (errors.Count > 0) - { - _error = string.Join("; ", errors); - return; - } - _receipt = await Deposit.DepositAsync(fact, new Identity(_user, IdentityKind.Human), CancellationToken.None); - Snackbar.Add($"stored {_receipt.Path}", Severity.Success); - _title = _source = _tags = _content = null; + _recorded = await Record.RecordAsync(_statement, new Identity(_user, IdentityKind.Human), Drafter, CancellationToken.None); + Snackbar.Add($"recorded {_recorded.Receipt.Path}", Severity.Success); + _statement = null; await LoadRawAsync(); } - catch (HttpRequestException e) + catch (Exception e) when (e is HttpRequestException or InvalidOperationException) { - _error = $"The store did not accept the fact: {e.Message}"; + _error = e.Message; } finally { diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index 1673c5e..d3dc833 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -74,14 +74,16 @@ foreach (var (name, answer) in wiki.AnswerModels.Models) switch (answer.Adapter) { case "Anthropic": - builder.Services.AddAnthropicAnswerModel(name, o => + Action anthropic = o => { o.BaseUrl = answer.Url; o.Model = answer.Model; o.ModelId = name; o.MaxTokens = answer.MaxTokens; o.ApiKey = builder.Configuration[$"Wiki:AnswerModels:Models:{name}:ApiKey"] ?? string.Empty; - }); + }; + builder.Services.AddAnthropicAnswerModel(name, anthropic); + builder.Services.AddAnthropicFactDrafter(name, anthropic); break; default: throw new InvalidOperationException($"unknown answer-model adapter '{answer.Adapter}' for '{name}'"); @@ -90,6 +92,10 @@ foreach (var (name, answer) in wiki.AnswerModels.Models) builder.Services.AddSingleton>(sp => wiki.AnswerModels.Models.Keys.Select(sp.GetRequiredKeyedService).ToList()); builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService(wiki.AnswerModels.Active)); +// Fact drafters ride with the answer models: same entries, same keys. +builder.Services.AddSingleton>(sp => + wiki.AnswerModels.Models.Keys.Select(sp.GetRequiredKeyedService).ToList()); +builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService(wiki.AnswerModels.Active)); // One adapter may serve several ports; list it once. builder.Services.AddSingleton>(sp => { @@ -101,6 +107,7 @@ builder.Services.AddSingleton>(sp => .. sp.GetRequiredService>(), .. sp.GetRequiredService>(), .. sp.GetRequiredService>(), + .. sp.GetRequiredService>(), ]; return all.Distinct().ToList(); }); @@ -114,6 +121,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/test/Wiki.Application.Tests/RecordStatementUseCaseTests.cs b/test/Wiki.Application.Tests/RecordStatementUseCaseTests.cs new file mode 100644 index 0000000..3525d8b --- /dev/null +++ b/test/Wiki.Application.Tests/RecordStatementUseCaseTests.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Time.Testing; +using Wiki.Application.Facts; +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Application.Tests; + +public sealed class FakeFactDrafter : IFactDrafter +{ + public List Statements { get; } = []; + public FactDraft Draft { get; set; } = new("Fußbodenheizung im Erdgeschoss", "Alle Räume im Erdgeschoss haben Fußbodenheizung; die Garderobe hat keine.", ["heating", "ground-floor"]); + public string ModelId => "fake-drafter"; + public string Name => "fake-drafter"; + + public Task DraftAsync(FactStatement statement, CancellationToken ct) + { + Statements.Add(statement); + return Task.FromResult(Draft); + } + + public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch)); +} + +public class RecordStatementUseCaseTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 25, 8, 0, 0, TimeSpan.Zero); + private static readonly Identity By = new("k.renner", IdentityKind.Human); + private const string Statement = "Alle Räume im Erdgeschoss haben Fußbodenheizung außer die Garderobe"; + + [Fact] + public async Task StatementBecomesARawSourceUnderThePersonWithTheVerbatimText() + { + var store = new FakeStore(); + var drafter = new FakeFactDrafter(); + var useCase = new RecordStatementUseCase(new DepositFactUseCase(store, new FakeTimeProvider(Now)), new FakeTimeProvider(Now)); + + var recorded = await useCase.RecordAsync(" " + Statement + "\n", By, drafter, CancellationToken.None); + + var sent = Assert.Single(drafter.Statements); + Assert.Equal(Statement, sent.Text); + Assert.Equal(By, sent.By); + Assert.Equal("raw/2026-09-25-fu-bodenheizung-im-erdgeschoss.md", recorded.Receipt.Path); + var text = store.Files[recorded.Receipt.Path]; + Assert.Contains("title: Fußbodenheizung im Erdgeschoss\n", text); + Assert.Contains("source: stated by k.renner on 2026-09-25 via the wiki\n", text); + Assert.Contains("captured_by: k.renner\n", text); + Assert.Contains("tags: [heating, ground-floor]\n", text); + Assert.Contains("die Garderobe hat keine.\n\nStatement, verbatim:\n\n> " + Statement + "\n", text); + } + + [Fact] + public async Task ADraftThatIsNoRawSourceIsNotStored() + { + var store = new FakeStore(); + var drafter = new FakeFactDrafter { Draft = new FactDraft(" ", "the verbatim statement always makes the body long enough; only a missing title fails", []) }; + var useCase = new RecordStatementUseCase(new DepositFactUseCase(store, new FakeTimeProvider(Now)), new FakeTimeProvider(Now)); + + await Assert.ThrowsAsync(() => useCase.RecordAsync(Statement, By, drafter, CancellationToken.None)); + Assert.Empty(store.Files); + } + + [Fact] + public async Task EmptyStatementsNeverReachTheDrafter() + { + var drafter = new FakeFactDrafter(); + var useCase = new RecordStatementUseCase(new DepositFactUseCase(new FakeStore(), new FakeTimeProvider(Now)), new FakeTimeProvider(Now)); + await Assert.ThrowsAsync(() => useCase.RecordAsync(" \n", By, drafter, CancellationToken.None)); + Assert.Empty(drafter.Statements); + } +} diff --git a/test/Wiki.Contracts/Anthropic/AnthropicFactDrafterContractTests.cs b/test/Wiki.Contracts/Anthropic/AnthropicFactDrafterContractTests.cs new file mode 100644 index 0000000..4cabfd7 --- /dev/null +++ b/test/Wiki.Contracts/Anthropic/AnthropicFactDrafterContractTests.cs @@ -0,0 +1,44 @@ +using Wiki.Adapters.Anthropic; +using Wiki.Core.Ports; + +namespace Wiki.Contracts.Anthropic; + +/// Runs the shared against the Anthropic adapter over a fake server that replies with a JSON draft. +public sealed class AnthropicFactDrafterContractTests : FactDrafterContract +{ + internal const string DraftJson = + "{\"title\": \"Fußbodenheizung im Erdgeschoss\", " + + "\"content\": \"Alle Räume im Erdgeschoss haben Fußbodenheizung. Die Garderobe ist die einzige Ausnahme.\", " + + "\"tags\": [\"Heating\", \"floor heating\", \"ground-floor\", \"heating\"]}"; + + protected override Task CreateDrafterAsync() + { + var handler = new FakeAnthropicHandler { ResponseText = DraftJson }; + var client = new HttpClient(handler); + var options = new AnthropicOptions + { + BaseUrl = "https://anthropic.test", + Model = "claude-sonnet-5", + ApiKey = "test-key", + }; + + IFactDrafter drafter = new AnthropicFactDrafter(client, options, delay: (_, _) => Task.CompletedTask); + return Task.FromResult(drafter); + } + + [Fact] + public void ParseTakesTheObjectOutOfSurroundingTextAndNormalisesTags() + { + var draft = FactDraftPrompt.Parse("Here you go:\n```json\n" + DraftJson + "\n```"); + Assert.Equal("Fußbodenheizung im Erdgeschoss", draft.Title); + Assert.Equal(["heating", "floor-heating", "ground-floor"], draft.Tags); + } + + [Fact] + public void ParseRejectsRepliesWithoutTitleOrContent() + { + Assert.Throws(() => FactDraftPrompt.Parse("no json here")); + Assert.Throws(() => FactDraftPrompt.Parse("{\"title\": \"x\"}")); + Assert.Throws(() => FactDraftPrompt.Parse("{\"title\": \"x\", \"content\": }")); + } +} diff --git a/test/Wiki.Contracts/Anthropic/FakeAnthropicHandler.cs b/test/Wiki.Contracts/Anthropic/FakeAnthropicHandler.cs index c730c87..51696c6 100644 --- a/test/Wiki.Contracts/Anthropic/FakeAnthropicHandler.cs +++ b/test/Wiki.Contracts/Anthropic/FakeAnthropicHandler.cs @@ -18,6 +18,9 @@ internal sealed class FakeAnthropicHandler : HttpMessageHandler public int TooManyRequestsBeforeSuccess { get; set; } + /// The text the fake streams back, in three deltas. + public string ResponseText { get; set; } = EchoText; + public bool RespondWithErrorEvent { get; set; } public string? LastRequestJson { get; private set; } @@ -72,7 +75,7 @@ internal sealed class FakeAnthropicHandler : HttpMessageHandler throw new InvalidOperationException($"Malformed Anthropic request: {body}"); } - var sse = RespondWithErrorEvent ? BuildErrorSse() : BuildSuccessSse(body.Length); + var sse = RespondWithErrorEvent ? BuildErrorSse() : BuildSuccessSse(body.Length, ResponseText); return new HttpResponseMessage(HttpStatusCode.OK) { @@ -80,10 +83,10 @@ internal sealed class FakeAnthropicHandler : HttpMessageHandler }; } - private static string BuildSuccessSse(int requestBodyLength) + private static string BuildSuccessSse(int requestBodyLength, string text) { var inputTokens = requestBodyLength / 4; - var words = EchoText.Split(' '); + var words = text.Split(' '); var third = words.Length / 3; var deltas = new[] { diff --git a/test/Wiki.Contracts/FactDrafterContract.cs b/test/Wiki.Contracts/FactDrafterContract.cs new file mode 100644 index 0000000..fb225cd --- /dev/null +++ b/test/Wiki.Contracts/FactDrafterContract.cs @@ -0,0 +1,47 @@ +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Contracts; + +/// +/// Every adapter derives from this. The adapter may talk to a fake +/// server; the contract checks the draft's shape and that empty input is refused before any call. +/// +public abstract class FactDrafterContract +{ + protected abstract Task CreateDrafterAsync(); + + protected static FactStatement Statement() => new( + "Alle Räume im Erdgeschoss haben Fußbodenheizung außer die Garderobe.", + new Identity("k.renner", IdentityKind.Human), + new DateTimeOffset(2026, 9, 25, 8, 0, 0, TimeSpan.Zero)); + + [Fact] + public async Task DraftsTitleContentAndSlugTags() + { + var drafter = await CreateDrafterAsync(); + var draft = await drafter.DraftAsync(Statement(), CancellationToken.None); + + Assert.False(string.IsNullOrWhiteSpace(draft.Title)); + Assert.True(draft.Content.Trim().Length >= RawSource.MinimumContentLength); + Assert.NotEmpty(draft.Tags); + Assert.All(draft.Tags, t => Assert.Matches("^[a-z0-9]+(-[a-z0-9]+)*$", t)); + Assert.Equal(draft.Tags.Count, draft.Tags.Distinct().Count()); + } + + [Fact] + public async Task RefusesAnEmptyStatement() + { + var drafter = await CreateDrafterAsync(); + var empty = Statement() with { Text = " " }; + await Assert.ThrowsAsync(() => drafter.DraftAsync(empty, CancellationToken.None)); + } + + [Fact] + public async Task ReportsHealth() + { + var drafter = await CreateDrafterAsync(); + var health = await drafter.CheckAsync(CancellationToken.None); + Assert.Equal(drafter.Name, health.Adapter); + } +} diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs index b013147..2e1a158 100644 --- a/test/Wiki.Host.Tests/EndpointTests.cs +++ b/test/Wiki.Host.Tests/EndpointTests.cs @@ -48,6 +48,11 @@ public sealed class WikiHostFactory : WebApplicationFactory services.RemoveAll(); services.AddSingleton(answer); services.AddSingleton>([answer]); + var drafter = new FakeFactDrafter(); + services.AddKeyedSingleton("claude-sonnet", drafter); + services.RemoveAll(); + services.AddSingleton(drafter); + services.AddSingleton>([drafter]); }); } } diff --git a/test/Wiki.Host.Tests/Fakes.cs b/test/Wiki.Host.Tests/Fakes.cs index 35146ac..e9ba1bc 100644 --- a/test/Wiki.Host.Tests/Fakes.cs +++ b/test/Wiki.Host.Tests/Fakes.cs @@ -105,3 +105,18 @@ public sealed class FakeEmbeddings(string modelId) : IEmbeddingModel public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch)); } + +public sealed class FakeFactDrafter : IFactDrafter +{ + public string ModelId => "fake-drafter"; + public string Name => "fake-drafter"; + + public Task DraftAsync(FactStatement statement, CancellationToken ct) + { + var words = statement.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var title = string.Join(' ', words.Take(5)); + return Task.FromResult(new FactDraft(title, statement.Text, ["fake"])); + } + + public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch)); +}