1 Commits
Author SHA1 Message Date
claude-botandClaude Opus 5.5 e9ffad448f fix(encyclopedia): room for thinking, merge duplicate new slugs, header timeouts retryable
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:43:24 +01:00
9 changed files with 127 additions and 10 deletions
@@ -222,7 +222,19 @@ internal sealed class MessagesTransport
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);
HttpResponseMessage response;
try
{
response = await _http.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
}
catch (OperationCanceledException e) when (!ct.IsCancellationRequested)
{
// HttpClient.Timeout while waiting for headers
throw new HttpRequestException(HttpRequestError.ResponseEnded,
"anthropic: no response headers before the HTTP timeout",
new TimeoutException("The request timed out.", e));
}
if (response.IsSuccessStatusCode)
{
return response;
@@ -70,7 +70,17 @@ public sealed class AssignNotesUseCase(ITextModel model, EncyclopediaSettings se
var proposed = new Dictionary<string, PlannedArticle>(StringComparer.Ordinal);
if (root["new"] is JsonArray items)
foreach (var item in items)
if (item is JsonObject o && ReadNew(o, candidateSlugs, proposed.Keys, plan) is { } a) proposed[a.Slug] = a;
{
if (item is not JsonObject o) continue;
// a "new" slug that is a live article is a reference to it, not a new article
if (Text(o["slug"]) is { } existing && plan.Find(existing) is not null)
{
log.LogInformation("Merged proposed new article {Slug} into the existing article", existing);
candidateSlugs.Add(existing);
continue;
}
if (ReadNew(o, candidateSlugs, proposed.Keys, plan) is { } a) proposed[a.Slug] = a;
}
var inBatch = batch.Select(n => n.Slug).ToHashSet(StringComparer.Ordinal);
var assignments = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
@@ -20,6 +20,7 @@ public sealed record EncyclopediaSettings(
int MaxSourcesPerArticle = 120,
int FoldMinSources = 3,
int FoldMinChars = 4000,
int AssignMaxTokens = 8000,
int SplitMaxTokens = 8000,
int FoldMaxTokens = 1000);
// thinking tokens count against max_tokens
int AssignMaxTokens = 32000,
int SplitMaxTokens = 32000,
int FoldMaxTokens = 8000);
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Wiki.Application.Encyclopedia;
/// </summary>
public static class Prompts
{
public const int Version = 6;
public const int Version = 7;
public const string Assign = """
[assign]
+4 -3
View File
@@ -101,7 +101,8 @@ public sealed class EncyclopediaOptions
public int MaxSourcesPerArticle { get; set; } = 120;
public int FoldMinSources { get; set; } = 3;
public int FoldMinChars { get; set; } = 4000;
public int AssignMaxTokens { get; set; } = 8000;
public int SplitMaxTokens { get; set; } = 8000;
public int FoldMaxTokens { get; set; } = 1000;
// thinking tokens count against max_tokens
public int AssignMaxTokens { get; set; } = 32000;
public int SplitMaxTokens { get; set; } = 32000;
public int FoldMaxTokens { get; set; } = 8000;
}
@@ -69,7 +69,7 @@ public class AssignNotesUseCaseTests
}
[Fact]
public async Task Assign_NewWithTakenSlug_DroppedAndItsNotesUnassigned()
public async Task Assign_NewWithExistingNonCandidateSlug_MergedIntoExistingArticle()
{
var heating = Article("heating");
var plan = PlanWith(heating, Article("garden"));
@@ -80,6 +80,43 @@ public class AssignNotesUseCaseTests
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
Assert.False(outcome.Unusable);
Assert.Empty(outcome.New);
Assert.Equal(["garden"], outcome.Assignments["a"]);
Assert.Equal(["heating"], outcome.Assignments["b"]);
}
[Fact]
public async Task Assign_NewWithRedirectedSlug_DroppedAndItsNotesUnassigned()
{
var heating = Article("heating");
var plan = PlanWith(heating, Article("garden"), Article("yard"));
plan.Fold("yard", "garden", isPublic: false);
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"yard","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["yard"]},{"note":"b","articles":["heating"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
Assert.Empty(outcome.New);
Assert.False(outcome.Assignments.ContainsKey("a"));
Assert.Equal(["heating"], outcome.Assignments["b"]);
}
[Fact]
public async Task Assign_NewWithRetiredSlug_DroppedAndItsNotesUnassigned()
{
var heating = Article("heating");
var plan = PlanWith(heating, Article("garden", "gone"));
plan.RemoveNote("gone");
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["garden"]},{"note":"b","articles":["heating"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
Assert.Empty(outcome.New);
Assert.False(outcome.Assignments.ContainsKey("a"));
Assert.Equal(["heating"], outcome.Assignments["b"]);
@@ -0,0 +1,19 @@
using Wiki.Application.Encyclopedia;
namespace Wiki.Application.Tests;
public class EncyclopediaSettingsTests
{
[Fact]
public void Defaults_LeaveRoomForThinking()
{
var s = new EncyclopediaSettings(16000, 10, 300_000, [], null);
Assert.Equal(32000, s.AssignMaxTokens);
Assert.Equal(32000, s.SplitMaxTokens);
Assert.Equal(8000, s.FoldMaxTokens);
}
[Fact]
public void PromptsVersion_InvalidatesBatchesFailedAtTheOldCap() => Assert.Equal(7, Prompts.Version);
}
@@ -99,6 +99,27 @@ public sealed class AnthropicTextModelTests
Assert.Null(ex.StatusCode);
}
[Fact]
public async Task Complete_HeaderTimeout_ThrowsHttpRequestWithoutStatus()
{
var model = CreateModel(new FakeAnthropicHandler { TimeOutBeforeHeaders = true });
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => model.CompleteAsync("sys", "user", 16000, CancellationToken.None));
Assert.IsType<TimeoutException>(ex.InnerException);
Assert.Null(ex.StatusCode);
}
[Fact]
public async Task Complete_CallerCancelsBeforeHeaders_ThrowsOperationCanceled()
{
var model = CreateModel(new FakeAnthropicHandler { HangBeforeHeaders = true });
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => model.CompleteAsync("sys", "user", 16000, cts.Token).WaitAsync(TimeSpan.FromSeconds(10)));
}
[Fact]
public async Task Complete_PassesMaxTokensAndSystem()
{
@@ -32,6 +32,12 @@ internal sealed class FakeAnthropicHandler : HttpMessageHandler
/// <summary>When set, the stream sends its message_start event and then nothing, until the request is cancelled.</summary>
public bool StallAfterFirstEvent { get; set; }
/// <summary>When set, a POST fails like an HttpClient.Timeout before headers: TaskCanceledException, caller token not cancelled.</summary>
public bool TimeOutBeforeHeaders { get; set; }
/// <summary>When set, a POST never answers until the request is cancelled.</summary>
public bool HangBeforeHeaders { get; set; }
/// <summary>When set, every POST is answered with this status and <see cref="FailureBody"/>.</summary>
public HttpStatusCode? FailWithStatus { get; set; }
@@ -64,6 +70,16 @@ internal sealed class FakeAnthropicHandler : HttpMessageHandler
var body = await request.Content!.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
LastRequestJson = body;
if (TimeOutBeforeHeaders)
{
throw new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout.", new TimeoutException());
}
if (HangBeforeHeaders)
{
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
}
if (_tooManyRequestsServed < TooManyRequestsBeforeSuccess)
{
_tooManyRequestsServed++;