From a9d0dfdc7607ff6d6e60afbfbe7c14973ff113a8 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 10 Jun 2025 23:34:33 -0500 Subject: [PATCH] feat: initial pass at citations in request and response --- src/AnthropicClient/Json/CitationConverter.cs | 28 ++++++++++++ .../Json/JsonSerializationOptions.cs | 1 + src/AnthropicClient/Models/CitationType.cs | 8 ++++ src/AnthropicClient/Models/DocumentContent.cs | 26 +++++++++++ src/AnthropicClient/Models/TextContent.cs | 43 +++++++++++++++++++ .../Models/TextDocumentSource.cs | 11 +++++ .../EndToEnd/AnthropicApiClientTests.cs | 29 +++++++++++++ 7 files changed, 146 insertions(+) create mode 100644 src/AnthropicClient/Json/CitationConverter.cs create mode 100644 src/AnthropicClient/Models/CitationType.cs create mode 100644 src/AnthropicClient/Models/TextDocumentSource.cs diff --git a/src/AnthropicClient/Json/CitationConverter.cs b/src/AnthropicClient/Json/CitationConverter.cs new file mode 100644 index 0000000..db4cf7c --- /dev/null +++ b/src/AnthropicClient/Json/CitationConverter.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using AnthropicClient.Models; + +namespace AnthropicClient.Json; + +class CitationConverter : JsonConverter +{ + public override Citation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var jsonDocument = JsonDocument.ParseValue(ref reader); + var root = jsonDocument.RootElement; + var type = root.GetProperty("type").GetString(); + return type switch + { + CitationType.CharacterLocation => JsonSerializer.Deserialize(root.GetRawText(), options)!, + CitationType.PageLocation => JsonSerializer.Deserialize(root.GetRawText(), options)!, + CitationType.ContentBlockLocation => JsonSerializer.Deserialize(root.GetRawText(), options)!, + _ => throw new JsonException($"Unknown content type: {type}") + }; + } + + public override void Write(Utf8JsonWriter writer, Citation value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} \ No newline at end of file diff --git a/src/AnthropicClient/Json/JsonSerializationOptions.cs b/src/AnthropicClient/Json/JsonSerializationOptions.cs index c1d5c40..5e426b3 100644 --- a/src/AnthropicClient/Json/JsonSerializationOptions.cs +++ b/src/AnthropicClient/Json/JsonSerializationOptions.cs @@ -18,6 +18,7 @@ static class JsonSerializationOptions new ContentDeltaConverter(), new JsonStringEnumConverter(), new MessageBatchResultConverter(), + new CitationConverter(), }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; diff --git a/src/AnthropicClient/Models/CitationType.cs b/src/AnthropicClient/Models/CitationType.cs new file mode 100644 index 0000000..daeda7e --- /dev/null +++ b/src/AnthropicClient/Models/CitationType.cs @@ -0,0 +1,8 @@ +namespace AnthropicClient.Models; + +public static class CitationType +{ + public const string CharacterLocation = "char_location"; + public const string PageLocation = "page_location"; + public const string ContentBlockLocation = "content_block_location"; +} \ No newline at end of file diff --git a/src/AnthropicClient/Models/DocumentContent.cs b/src/AnthropicClient/Models/DocumentContent.cs index 1389d84..cd86c29 100644 --- a/src/AnthropicClient/Models/DocumentContent.cs +++ b/src/AnthropicClient/Models/DocumentContent.cs @@ -14,6 +14,12 @@ public class DocumentContent : Content /// public DocumentSource Source { get; init; } = new(); + public string Title { get; init; } = string.Empty; + + public string Context { get; init; } = string.Empty; + + public CitationOption Citations { get; init; } = new CitationOption(); + [JsonConstructor] internal DocumentContent() { @@ -53,4 +59,24 @@ public class DocumentContent : Content Source = new(mediaType, data); } + + public DocumentContent(DocumentSource source) : base(ContentType.Document) + { + ArgumentValidator.ThrowIfNull(source, nameof(source)); + + Source = source; + } + + public DocumentContent(DocumentSource source, CacheControl cacheControl) : base(ContentType.Document, cacheControl) + { + ArgumentValidator.ThrowIfNull(source, nameof(source)); + + Source = source; + } +} + + +public class CitationOption +{ + public bool Enabled { get; init; } } \ No newline at end of file diff --git a/src/AnthropicClient/Models/TextContent.cs b/src/AnthropicClient/Models/TextContent.cs index a6751cf..924ec1b 100644 --- a/src/AnthropicClient/Models/TextContent.cs +++ b/src/AnthropicClient/Models/TextContent.cs @@ -14,6 +14,8 @@ public class TextContent : Content /// public string Text { get; init; } = string.Empty; + public Citation[] Citations { get; init; } = []; + [JsonConstructor] internal TextContent() : base(ContentType.Text) { @@ -50,4 +52,45 @@ public class TextContent : Content Text = text; } +} + +public abstract class Citation +{ + public string Type { get; init; } = string.Empty; + + [JsonPropertyName("cited_text")] + public string CitedText { get; init; } = string.Empty; + + [JsonPropertyName("document_index")] + public int DocumentIndex { get; init; } + + [JsonPropertyName("document_title")] + public string DocumentTitle { get; init; } = string.Empty; +} + +public class CharacterLocationCitation : Citation +{ + [JsonPropertyName("start_char_index")] + public int StartCharIndex { get; init; } + + [JsonPropertyName("end_char_index")] + public int EndCharIndex { get; init; } +} + +public class PageLocationCitation : Citation +{ + [JsonPropertyName("start_page_number")] + public int StartPageNumber { get; init; } + + [JsonPropertyName("end_page_number")] + public int EndPageNumber { get; init; } +} + +public class ContentBlockLocationCitation : Citation +{ + [JsonPropertyName("start_block_index")] + public int StartBlockIndex { get; init; } + + [JsonPropertyName("end_block_index")] + public int EndBlockIndex { get; init; } } \ No newline at end of file diff --git a/src/AnthropicClient/Models/TextDocumentSource.cs b/src/AnthropicClient/Models/TextDocumentSource.cs new file mode 100644 index 0000000..d4dbf71 --- /dev/null +++ b/src/AnthropicClient/Models/TextDocumentSource.cs @@ -0,0 +1,11 @@ +namespace AnthropicClient.Models; + +public class TextDocumentSource : DocumentSource +{ + public TextDocumentSource(string data) : base("text/plain", data) + { + Type = "text"; + } +} + + diff --git a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs index 8fa81f6..230e0d3 100644 --- a/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs +++ b/tests/AnthropicClient.Tests/EndToEnd/AnthropicApiClientTests.cs @@ -286,6 +286,35 @@ public class AnthropicApiClientTests(ConfigurationFixture configFixture) : EndTo resultTwo.Value.Usage.CacheReadInputTokens.Should().BeGreaterThan(0); } + [Fact] + public async Task CreateMessageAsync_WhenCitationsAreEnabled_ItShouldReturnCitationsInResponse() + { + var request = new MessageRequest( + model: AnthropicModels.Claude3Haiku, + messages: [ + new( + MessageRole.User, + [ + new DocumentContent( + new TextDocumentSource("The grass is green. The sky is blue.") + ) + { + Title = "My Document", + Context = "This is a trustworthy document.", + Citations = new() { Enabled = true } + }, + new TextContent("What color is the grass and sky?"), + ] + ) + ] + ); + + var result = await _client.CreateMessageAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Content.OfType().SelectMany(c => c.Citations).Should().NotBeEmpty(); + } + [Fact] public async Task CountMessageTokensAsync_WhenCalled_ItShouldReturnResponse() {