feat(facts): one text box; the drafter turns a statement into a raw source #12

Merged
k.renner merged 1 commits from feat/fact-from-statement into main 2026-09-25 06:57:05 +00:00
17 changed files with 651 additions and 270 deletions
+2 -1
View File
@@ -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__<name>`,
`Wiki__Judges__Models__<name>__ApiKey`, `Wiki__AnswerModels__Models__<name>__ApiKey`).
`Wiki__Judges__Models__<name>__ApiKey`, `Wiki__AnswerModels__Models__<name>__ApiKey`). Each
answer-model entry also provides the fact drafter of the same name (`/facts`).
## Run
@@ -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;
/// <summary>
/// Answers questions via Anthropic's Messages API, streamed over server-sent events. One HTTP
/// request per <see cref="AnswerAsync"/> call, retried on 429/529 with the server's Retry-After
/// (or exponential backoff), never retried for anything else.
/// request per <see cref="AnswerAsync"/> call; see <see cref="MessagesTransport"/> for retries.
/// </summary>
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<TimeSpan, CancellationToken, Task> _delay;
private readonly MessagesTransport _transport;
public AnthropicAnswerModel(HttpClient http, AnthropicOptions options, Func<TimeSpan, CancellationToken, Task>? 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<AnswerChunk> AnswerAsync(AnswerRequest request, [EnumeratorCancellation] CancellationToken ct)
public IAsyncEnumerable<AnswerChunk> 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<AdapterHealth> 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<HttpResponseMessage> 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<AdapterHealth> CheckAsync(CancellationToken ct) => _transport.CheckAsync(Name, ct);
}
@@ -0,0 +1,38 @@
using System.Text;
using Wiki.Core.Model;
using Wiki.Core.Ports;
namespace Wiki.Adapters.Anthropic;
/// <summary>Turns a statement into raw-source fields with one Messages API call. Same transport and options as the answer model.</summary>
public sealed class AnthropicFactDrafter : IFactDrafter
{
private readonly MessagesTransport _transport;
public AnthropicFactDrafter(HttpClient http, AnthropicOptions options, Func<TimeSpan, CancellationToken, Task>? delay = null)
{
_transport = new MessagesTransport(http, options, delay);
ModelId = options.ModelId;
}
public string ModelId { get; }
public string Name => $"anthropic-facts:{ModelId}";
public async Task<FactDraft> 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<AdapterHealth> CheckAsync(CancellationToken ct) => _transport.CheckAsync(Name, ct);
}
@@ -0,0 +1,68 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Wiki.Core.Model;
namespace Wiki.Adapters.Anthropic;
/// <summary>Builds the prompt that turns a statement into raw-source fields, and parses the model's JSON. Pure.</summary>
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());
}
/// <summary>The draft in the model's reply. Tolerates text around the JSON object; rejects anything without title and content.</summary>
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('-');
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<TimeSpan, CancellationToken, Task> _delay;
public MessagesTransport(HttpClient http, AnthropicOptions options, Func<TimeSpan, CancellationToken, Task>? delay)
{
_http = http;
_options = options;
_delay = delay ?? Task.Delay;
}
public async IAsyncEnumerable<AnswerChunk> 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<AdapterHealth> 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<HttpResponseMessage> 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;
}
}
@@ -39,4 +39,38 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// Registers an <see cref="IFactDrafter"/> backed by Anthropic's Messages API, keyed by
/// <paramref name="name"/>. Usually registered alongside the answer model of the same name.
/// </summary>
public static IServiceCollection AddAnthropicFactDrafter(
this IServiceCollection services,
string name,
Action<AnthropicOptions> 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<IFactDrafter>(name, (sp, _) =>
{
var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
var client = httpClientFactory.CreateClient(httpClientName);
return new AnthropicFactDrafter(client, options);
});
return services;
}
}
@@ -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);
/// <summary>
/// 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.
/// </summary>
public sealed class RecordStatementUseCase(DepositFactUseCase deposit, TimeProvider clock)
{
public async Task<RecordedStatement> 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";
}
+7
View File
@@ -0,0 +1,7 @@
namespace Wiki.Core.Model;
/// <summary>What a person said, by whom and when. The drafter sees nothing else.</summary>
public sealed record FactStatement(string Text, Identity By, DateTimeOffset StatedAt);
/// <summary>A statement normalised into the fields of a raw source. Source and provenance are the application's to add.</summary>
public sealed record FactDraft(string Title, string Content, IReadOnlyList<string> Tags);
+7
View File
@@ -88,3 +88,10 @@ public interface IAnswerModel : IAdapterHealth
string ModelId { get; }
IAsyncEnumerable<AnswerChunk> AnswerAsync(AnswerRequest request, CancellationToken ct);
}
/// <summary>A person's statement into the fields of a raw source. Sees only the statement.</summary>
public interface IFactDrafter : IAdapterHealth
{
string ModelId { get; }
Task<FactDraft> DraftAsync(FactStatement statement, CancellationToken ct);
}
+33 -63
View File
@@ -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
<PageTitle>Facts</PageTitle>
<MudText Typo="Typo.h4" GutterBottom="true">Facts</MudText>
<MudText Typo="Typo.body2" Class="mb-4" Color="Color.Secondary">
A fact is one verified observation with its provenance. It lands verbatim in <code>raw/</code> 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.
</MudText>
@if (_user is null)
{
<MudAlert Severity="Severity.Warning" Class="mb-4">Not signed in. Facts are attributed to the person depositing them, so this page needs the Authentik sign-in at wiki.renner.uno.</MudAlert>
<MudAlert Severity="Severity.Warning" Class="mb-4">Not signed in. Facts are attributed to the person stating them, so this page needs the Authentik sign-in at wiki.renner.uno.</MudAlert>
}
else
{
<MudPaper Class="pa-4 mb-6">
<MudForm @ref="_form" @bind-IsValid="_valid">
<MudGrid>
<MudItem xs="12" md="8">
<MudTextField T="string" @bind-Value="_title" Label="Title" Variant="Variant.Outlined" Required="true" RequiredError="title is required" Immediate="true" Disabled="@_running" />
</MudItem>
<MudItem xs="12" md="4">
<MudDatePicker Label="Observed on" @bind-Date="_observedDate" Variant="Variant.Outlined" Disabled="@_running" />
</MudItem>
<MudItem xs="12" md="8">
<MudTextField T="string" @bind-Value="_source" Label="Source" HelperText="where the fact was observed or quoted from: a command, a URL, a document" Variant="Variant.Outlined" Required="true" RequiredError="source is required" Immediate="true" Disabled="@_running" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField T="string" @bind-Value="_tags" Label="Tags" HelperText="comma separated" Variant="Variant.Outlined" Immediate="true" Disabled="@_running" />
</MudItem>
<MudItem xs="12">
<MudTextField T="string" @bind-Value="_content" Label="Observation" Lines="8" Variant="Variant.Outlined" Required="true" Immediate="true" Disabled="@_running"
Validation="@((string s) => s is not null && s.Trim().Length >= RawSource.MinimumContentLength ? null : $"at least {RawSource.MinimumContentLength} characters of observation")"
Counter="0" HelperText="@($"{_content?.Trim().Length ?? 0} characters; markdown is fine")" />
</MudItem>
</MudGrid>
</MudForm>
<MudStack Row Justify="Justify.FlexEnd" AlignItems="AlignItems.Center" Spacing="2" Class="mt-2">
<form @onsubmit="RecordAsync" @onsubmit:preventDefault="true">
<MudTextField T="string" @bind-Value="_statement" Immediate="true" Lines="3" Label="What did you observe?" Variant="Variant.Outlined" Disabled="@_running" Class="mb-2" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
<MudText Typo="Typo.caption" Color="Color.Secondary">captured_by: @_user</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="DepositAsync" Disabled="@(_running || !_valid)">Deposit</MudButton>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Disabled="@(_running || string.IsNullOrWhiteSpace(_statement))">Record</MudButton>
</MudStack>
</MudPaper>
</form>
}
@if (_running)
@@ -57,11 +37,21 @@ else
{
<MudAlert Severity="Severity.Error" Class="mb-4">@_error</MudAlert>
}
@if (_receipt is not null)
@if (_recorded is not null)
{
<MudAlert Severity="Severity.Success" Class="mb-4">
Stored as <MudLink Href="@($"raw/{Slug(_receipt.Path)}")">@_receipt.Path</MudLink> (commit @_receipt.Commit[..Math.Min(8, _receipt.Commit.Length)]).
</MudAlert>
<MudPaper Class="pa-4 mb-6" Outlined="true">
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">@_recorded.Draft.Title</MudText>
<MudLink Href="@($"raw/{Slug(_recorded.Receipt.Path)}")">@_recorded.Receipt.Path</MudLink>
</MudStack>
<MudText Typo="Typo.body1" Style="white-space: pre-wrap" Class="mb-2">@_recorded.Draft.Content</MudText>
<MudStack Row Spacing="1">
@foreach (var t in _recorded.Draft.Tags)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@t</MudChip>
}
</MudStack>
</MudPaper>
}
<MudText Typo="Typo.subtitle1" Class="mb-2">Raw sources awaiting distillation</MudText>
@@ -95,22 +85,15 @@ else
@code {
[CascadingParameter] private Task<AuthenticationState>? 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<RawQueueEntry>? _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
{
+10 -2
View File
@@ -74,14 +74,16 @@ foreach (var (name, answer) in wiki.AnswerModels.Models)
switch (answer.Adapter)
{
case "Anthropic":
builder.Services.AddAnthropicAnswerModel(name, o =>
Action<Wiki.Adapters.Anthropic.AnthropicOptions> 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<IReadOnlyList<IAnswerModel>>(sp =>
wiki.AnswerModels.Models.Keys.Select(sp.GetRequiredKeyedService<IAnswerModel>).ToList());
builder.Services.AddSingleton<IAnswerModel>(sp => sp.GetRequiredKeyedService<IAnswerModel>(wiki.AnswerModels.Active));
// Fact drafters ride with the answer models: same entries, same keys.
builder.Services.AddSingleton<IReadOnlyList<IFactDrafter>>(sp =>
wiki.AnswerModels.Models.Keys.Select(sp.GetRequiredKeyedService<IFactDrafter>).ToList());
builder.Services.AddSingleton<IFactDrafter>(sp => sp.GetRequiredKeyedService<IFactDrafter>(wiki.AnswerModels.Active));
// One adapter may serve several ports; list it once.
builder.Services.AddSingleton<IReadOnlyList<IAdapterHealth>>(sp =>
{
@@ -101,6 +107,7 @@ builder.Services.AddSingleton<IReadOnlyList<IAdapterHealth>>(sp =>
.. sp.GetRequiredService<IReadOnlyList<IEmbeddingModel>>(),
.. sp.GetRequiredService<IReadOnlyList<IJudge>>(),
.. sp.GetRequiredService<IReadOnlyList<IAnswerModel>>(),
.. sp.GetRequiredService<IReadOnlyList<IFactDrafter>>(),
];
return all.Distinct().ToList();
});
@@ -114,6 +121,7 @@ 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<Wiki.Application.Facts.DepositFactUseCase>();
builder.Services.AddSingleton<Wiki.Application.Facts.RecordStatementUseCase>();
builder.Services.AddSingleton<Wiki.Application.Evaluation.EvaluateUseCase>();
builder.Services.AddSingleton<Wiki.Application.Questions.QuestionUseCase>();
builder.Services.AddSingleton<Wiki.Application.Questions.SaveAnswerUseCase>();
@@ -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<FactStatement> 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<FactDraft> DraftAsync(FactStatement statement, CancellationToken ct)
{
Statements.Add(statement);
return Task.FromResult(Draft);
}
public Task<AdapterHealth> 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<InvalidOperationException>(() => 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<ArgumentException>(() => useCase.RecordAsync(" \n", By, drafter, CancellationToken.None));
Assert.Empty(drafter.Statements);
}
}
@@ -0,0 +1,44 @@
using Wiki.Adapters.Anthropic;
using Wiki.Core.Ports;
namespace Wiki.Contracts.Anthropic;
/// <summary>Runs the shared <see cref="FactDrafterContract"/> against the Anthropic adapter over a fake server that replies with a JSON draft.</summary>
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<IFactDrafter> 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<InvalidOperationException>(() => FactDraftPrompt.Parse("no json here"));
Assert.Throws<InvalidOperationException>(() => FactDraftPrompt.Parse("{\"title\": \"x\"}"));
Assert.Throws<InvalidOperationException>(() => FactDraftPrompt.Parse("{\"title\": \"x\", \"content\": }"));
}
}
@@ -18,6 +18,9 @@ internal sealed class FakeAnthropicHandler : HttpMessageHandler
public int TooManyRequestsBeforeSuccess { get; set; }
/// <summary>The text the fake streams back, in three deltas.</summary>
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[]
{
@@ -0,0 +1,47 @@
using Wiki.Core.Model;
using Wiki.Core.Ports;
namespace Wiki.Contracts;
/// <summary>
/// Every <see cref="IFactDrafter"/> 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.
/// </summary>
public abstract class FactDrafterContract
{
protected abstract Task<IFactDrafter> 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<ArgumentException>(() => 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);
}
}
+5
View File
@@ -48,6 +48,11 @@ public sealed class WikiHostFactory : WebApplicationFactory<Program>
services.RemoveAll<IAnswerModel>();
services.AddSingleton<IAnswerModel>(answer);
services.AddSingleton<IReadOnlyList<IAnswerModel>>([answer]);
var drafter = new FakeFactDrafter();
services.AddKeyedSingleton<IFactDrafter>("claude-sonnet", drafter);
services.RemoveAll<IFactDrafter>();
services.AddSingleton<IFactDrafter>(drafter);
services.AddSingleton<IReadOnlyList<IFactDrafter>>([drafter]);
});
}
}
+15
View File
@@ -105,3 +105,18 @@ public sealed class FakeEmbeddings(string modelId) : IEmbeddingModel
public Task<AdapterHealth> 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<FactDraft> 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<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
}