diff --git a/src/Wiki.Adapters.Anthropic/MessagesTransport.cs b/src/Wiki.Adapters.Anthropic/MessagesTransport.cs index d620455..4d47ab3 100644 --- a/src/Wiki.Adapters.Anthropic/MessagesTransport.cs +++ b/src/Wiki.Adapters.Anthropic/MessagesTransport.cs @@ -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; diff --git a/src/Wiki.Application/Encyclopedia/AssignNotesUseCase.cs b/src/Wiki.Application/Encyclopedia/AssignNotesUseCase.cs index d38beff..be11071 100644 --- a/src/Wiki.Application/Encyclopedia/AssignNotesUseCase.cs +++ b/src/Wiki.Application/Encyclopedia/AssignNotesUseCase.cs @@ -70,7 +70,17 @@ public sealed class AssignNotesUseCase(ITextModel model, EncyclopediaSettings se var proposed = new Dictionary(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>(StringComparer.Ordinal); diff --git a/src/Wiki.Application/Encyclopedia/EncyclopediaSettings.cs b/src/Wiki.Application/Encyclopedia/EncyclopediaSettings.cs index 4fa2eab..ba0f33a 100644 --- a/src/Wiki.Application/Encyclopedia/EncyclopediaSettings.cs +++ b/src/Wiki.Application/Encyclopedia/EncyclopediaSettings.cs @@ -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); diff --git a/src/Wiki.Application/Encyclopedia/Prompts.cs b/src/Wiki.Application/Encyclopedia/Prompts.cs index e0c680a..9e66fb2 100644 --- a/src/Wiki.Application/Encyclopedia/Prompts.cs +++ b/src/Wiki.Application/Encyclopedia/Prompts.cs @@ -6,7 +6,7 @@ namespace Wiki.Application.Encyclopedia; /// public static class Prompts { - public const int Version = 6; + public const int Version = 7; public const string Assign = """ [assign] diff --git a/src/Wiki.Host/Composition/WikiOptions.cs b/src/Wiki.Host/Composition/WikiOptions.cs index 6bbcab6..64c00b4 100644 --- a/src/Wiki.Host/Composition/WikiOptions.cs +++ b/src/Wiki.Host/Composition/WikiOptions.cs @@ -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; } diff --git a/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs b/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs index 76e6208..ff153b0 100644 --- a/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs +++ b/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs @@ -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"]); diff --git a/test/Wiki.Application.Tests/EncyclopediaSettingsTests.cs b/test/Wiki.Application.Tests/EncyclopediaSettingsTests.cs new file mode 100644 index 0000000..78a553f --- /dev/null +++ b/test/Wiki.Application.Tests/EncyclopediaSettingsTests.cs @@ -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); +} diff --git a/test/Wiki.Contracts/Anthropic/AnthropicTextModelTests.cs b/test/Wiki.Contracts/Anthropic/AnthropicTextModelTests.cs index 61623d6..b364fc0 100644 --- a/test/Wiki.Contracts/Anthropic/AnthropicTextModelTests.cs +++ b/test/Wiki.Contracts/Anthropic/AnthropicTextModelTests.cs @@ -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(() => model.CompleteAsync("sys", "user", 16000, CancellationToken.None)); + + Assert.IsType(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( + () => model.CompleteAsync("sys", "user", 16000, cts.Token).WaitAsync(TimeSpan.FromSeconds(10))); + } + [Fact] public async Task Complete_PassesMaxTokensAndSystem() { diff --git a/test/Wiki.Contracts/Anthropic/FakeAnthropicHandler.cs b/test/Wiki.Contracts/Anthropic/FakeAnthropicHandler.cs index 79ebb78..e0b3678 100644 --- a/test/Wiki.Contracts/Anthropic/FakeAnthropicHandler.cs +++ b/test/Wiki.Contracts/Anthropic/FakeAnthropicHandler.cs @@ -32,6 +32,12 @@ internal sealed class FakeAnthropicHandler : HttpMessageHandler /// When set, the stream sends its message_start event and then nothing, until the request is cancelled. public bool StallAfterFirstEvent { get; set; } + /// When set, a POST fails like an HttpClient.Timeout before headers: TaskCanceledException, caller token not cancelled. + public bool TimeOutBeforeHeaders { get; set; } + + /// When set, a POST never answers until the request is cancelled. + public bool HangBeforeHeaders { get; set; } + /// When set, every POST is answered with this status and . 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++;