feat(facts): one text box; the drafter turns a statement into a raw source
IFactDrafter port: a statement in, title/content/tags out. Anthropic implements it over the transport the answer model already uses (extracted into MessagesTransport). RecordStatementUseCase drafts, sets the person as source, keeps the statement verbatim in the body and deposits through the existing use case. The Facts page is the text box and the receipt; the API shape for machines is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user