feat(facts): one text box; the drafter turns a statement into a raw source #12
@@ -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