diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java index d2960ddb935..da8324eb409 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java @@ -44,6 +44,7 @@ import org.springframework.ai.anthropic.api.AnthropicApi.Role; import org.springframework.ai.anthropic.api.AnthropicCacheOptions; import org.springframework.ai.anthropic.api.AnthropicCacheTtl; +import org.springframework.ai.anthropic.api.CitationDocument; import org.springframework.ai.anthropic.api.utils.CacheEligibilityResolver; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; @@ -322,12 +323,13 @@ private ChatResponse toChatResponse(ChatCompletionResponse chatCompletion, Usage List generations = new ArrayList<>(); List toolCalls = new ArrayList<>(); + CitationContext citationContext = new CitationContext(); for (ContentBlock content : chatCompletion.content()) { switch (content.type()) { case TEXT, TEXT_DELTA: - generations.add(new Generation( - AssistantMessage.builder().content(content.text()).properties(Map.of()).build(), - ChatGenerationMetadata.builder().finishReason(chatCompletion.stopReason()).build())); + Generation textGeneration = processTextContent(content, chatCompletion.stopReason(), + citationContext); + generations.add(textGeneration); break; case THINKING, THINKING_DELTA: Map thinkingProperties = new HashMap<>(); @@ -371,7 +373,101 @@ private ChatResponse toChatResponse(ChatCompletionResponse chatCompletion, Usage ChatGenerationMetadata.builder().finishReason(chatCompletion.stopReason()).build()); generations.add(toolCallGeneration); } - return new ChatResponse(generations, this.from(chatCompletion, usage)); + + // Create response metadata with citation information if present + ChatResponseMetadata.Builder metadataBuilder = ChatResponseMetadata.builder() + .id(chatCompletion.id()) + .model(chatCompletion.model()) + .usage(usage) + .keyValue("stop-reason", chatCompletion.stopReason()) + .keyValue("stop-sequence", chatCompletion.stopSequence()) + .keyValue("type", chatCompletion.type()); + + // Add citation metadata if citations were found + if (citationContext.hasCitations()) { + metadataBuilder.keyValue("citations", citationContext.getAllCitations()) + .keyValue("citationCount", citationContext.getTotalCitationCount()); + } + + ChatResponseMetadata responseMetadata = metadataBuilder.build(); + + return new ChatResponse(generations, responseMetadata); + } + + private Generation processTextContent(ContentBlock content, String stopReason, CitationContext citationContext) { + // Extract citations if present in the content block + if (content.citations() instanceof List) { + try { + @SuppressWarnings("unchecked") + List citationObjects = (List) content.citations(); + + List citations = new ArrayList<>(); + for (Object citationObj : citationObjects) { + if (citationObj instanceof Map) { + // Convert Map to CitationResponse using manual parsing + AnthropicApi.CitationResponse citationResponse = parseCitationFromMap((Map) citationObj); + citations.add(convertToCitation(citationResponse)); + } + else { + logger.warn("Unexpected citation object type: {}. Expected Map but got: {}. Skipping citation.", + citationObj.getClass().getName(), citationObj); + } + } + + if (!citations.isEmpty()) { + citationContext.addCitations(citations); + } + + } + catch (Exception e) { + logger.warn("Failed to parse citations from content block", e); + } + } + + return new Generation(new AssistantMessage(content.text()), + ChatGenerationMetadata.builder().finishReason(stopReason).build()); + } + + /** + * Parse citation data from Map (typically from JSON deserialization). Assumes all + * required fields are present and of correct types. + * @param citationMap the map containing citation data from API response + * @return parsed CitationResponse + */ + private AnthropicApi.CitationResponse parseCitationFromMap(Map citationMap) { + String type = (String) citationMap.get("type"); + String citedText = (String) citationMap.get("cited_text"); + Integer documentIndex = (Integer) citationMap.get("document_index"); + String documentTitle = (String) citationMap.get("document_title"); + + Integer startCharIndex = (Integer) citationMap.get("start_char_index"); + Integer endCharIndex = (Integer) citationMap.get("end_char_index"); + Integer startPageNumber = (Integer) citationMap.get("start_page_number"); + Integer endPageNumber = (Integer) citationMap.get("end_page_number"); + Integer startBlockIndex = (Integer) citationMap.get("start_block_index"); + Integer endBlockIndex = (Integer) citationMap.get("end_block_index"); + + return new AnthropicApi.CitationResponse(type, citedText, documentIndex, documentTitle, startCharIndex, + endCharIndex, startPageNumber, endPageNumber, startBlockIndex, endBlockIndex); + } + + /** + * Convert CitationResponse to Citation object. This method handles the conversion to + * avoid circular dependencies. + */ + private Citation convertToCitation(AnthropicApi.CitationResponse citationResponse) { + return switch (citationResponse.type()) { + case "char_location" -> Citation.ofCharLocation(citationResponse.citedText(), + citationResponse.documentIndex(), citationResponse.documentTitle(), + citationResponse.startCharIndex(), citationResponse.endCharIndex()); + case "page_location" -> Citation.ofPageLocation(citationResponse.citedText(), + citationResponse.documentIndex(), citationResponse.documentTitle(), + citationResponse.startPageNumber(), citationResponse.endPageNumber()); + case "content_block_location" -> Citation.ofContentBlockLocation(citationResponse.citedText(), + citationResponse.documentIndex(), citationResponse.documentTitle(), + citationResponse.startBlockIndex(), citationResponse.endBlockIndex()); + default -> throw new IllegalArgumentException("Unknown citation type: " + citationResponse.type()); + }; } private ChatResponseMetadata from(AnthropicApi.ChatCompletionResponse result) { @@ -479,6 +575,14 @@ Prompt buildRequestPrompt(Prompt prompt) { // Merge cache options that are Json-ignored requestOptions.setCacheOptions(runtimeOptions.getCacheOptions() != null ? runtimeOptions.getCacheOptions() : this.defaultOptions.getCacheOptions()); + + // Merge citation documents that are Json-ignored + if (runtimeOptions.getCitationDocuments() != null && !runtimeOptions.getCitationDocuments().isEmpty()) { + requestOptions.setCitationDocuments(runtimeOptions.getCitationDocuments()); + } + else if (this.defaultOptions.getCitationDocuments() != null) { + requestOptions.setCitationDocuments(this.defaultOptions.getCitationDocuments()); + } } else { requestOptions.setHttpHeaders(this.defaultOptions.getHttpHeaders()); @@ -486,6 +590,7 @@ Prompt buildRequestPrompt(Prompt prompt) { requestOptions.setToolNames(this.defaultOptions.getToolNames()); requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks()); requestOptions.setToolContext(this.defaultOptions.getToolContext()); + requestOptions.setCitationDocuments(this.defaultOptions.getCitationDocuments()); } ToolCallingChatOptions.validateToolCallbacks(requestOptions.getToolCallbacks()); @@ -610,12 +715,24 @@ private List buildMessages(Prompt prompt, CacheEligibilityReso } } + // Get citation documents from options + List citationDocuments = null; + if (prompt.getOptions() instanceof AnthropicChatOptions anthropicOptions) { + citationDocuments = anthropicOptions.getCitationDocuments(); + } + List result = new ArrayList<>(); for (int i = 0; i < allMessages.size(); i++) { Message message = allMessages.get(i); MessageType messageType = message.getMessageType(); if (messageType == MessageType.USER) { List contentBlocks = new ArrayList<>(); + // Add citation documents to the FIRST user message only + if (i == 0 && citationDocuments != null && !citationDocuments.isEmpty()) { + for (CitationDocument doc : citationDocuments) { + contentBlocks.add(doc.toContentBlock()); + } + } String content = message.getText(); // For conversation history caching, apply cache control to the // message immediately before the last user message. @@ -823,4 +940,30 @@ public AnthropicChatModel build() { } + /** + * Context object for tracking citations during response processing. Aggregates + * citations from multiple content blocks in a single response. + */ + class CitationContext { + + private final List allCitations = new ArrayList<>(); + + public void addCitations(List citations) { + this.allCitations.addAll(citations); + } + + public boolean hasCitations() { + return !this.allCitations.isEmpty(); + } + + public List getAllCitations() { + return new ArrayList<>(this.allCitations); + } + + public int getTotalCitationCount() { + return this.allCitations.size(); + } + + } + } diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java index 0b83d3275a5..8061bb71e42 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java @@ -33,6 +33,7 @@ import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest; import org.springframework.ai.anthropic.api.AnthropicCacheOptions; +import org.springframework.ai.anthropic.api.CitationDocument; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.tool.ToolCallback; import org.springframework.lang.Nullable; @@ -63,6 +64,17 @@ public class AnthropicChatOptions implements ToolCallingChatOptions { private @JsonProperty("tool_choice") AnthropicApi.ToolChoice toolChoice; private @JsonProperty("thinking") ChatCompletionRequest.ThinkingConfig thinking; + /** + * Documents to be used for citation-based responses. These documents will be + * converted to ContentBlocks and included in the first user message of the request. + * Citations indicating which parts of these documents were used in the response will + * be returned in the response metadata under the "citations" key. + * @see CitationDocument + * @see Citation + */ + @JsonIgnore + private List citationDocuments = new ArrayList<>(); + @JsonIgnore private AnthropicCacheOptions cacheOptions = AnthropicCacheOptions.DISABLED; @@ -127,6 +139,8 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions) .toolContext(fromOptions.getToolContext() != null ? new HashMap<>(fromOptions.getToolContext()) : null) .httpHeaders(fromOptions.getHttpHeaders() != null ? new HashMap<>(fromOptions.getHttpHeaders()) : null) .cacheOptions(fromOptions.getCacheOptions()) + .citationDocuments(fromOptions.getCitationDocuments() != null + ? new ArrayList<>(fromOptions.getCitationDocuments()) : null) .build(); } @@ -283,6 +297,34 @@ public void setHttpHeaders(Map httpHeaders) { this.httpHeaders = httpHeaders; } + public List getCitationDocuments() { + return this.citationDocuments; + } + + public void setCitationDocuments(List citationDocuments) { + Assert.notNull(citationDocuments, "Citation documents cannot be null"); + this.citationDocuments = citationDocuments; + } + + /** + * Validate that all citation documents have consistent citation settings. Anthropic + * requires all documents to have citations enabled if any do. + */ + public void validateCitationConsistency() { + if (this.citationDocuments.isEmpty()) { + return; + } + + boolean hasEnabledCitations = this.citationDocuments.stream().anyMatch(CitationDocument::isCitationsEnabled); + boolean hasDisabledCitations = this.citationDocuments.stream().anyMatch(doc -> !doc.isCitationsEnabled()); + + if (hasEnabledCitations && hasDisabledCitations) { + throw new IllegalArgumentException( + "Anthropic Citations API requires all documents to have consistent citation settings. " + + "Either enable citations for all documents or disable for all documents."); + } + } + @Override @SuppressWarnings("unchecked") public AnthropicChatOptions copy() { @@ -308,14 +350,16 @@ public boolean equals(Object o) { && Objects.equals(this.internalToolExecutionEnabled, that.internalToolExecutionEnabled) && Objects.equals(this.toolContext, that.toolContext) && Objects.equals(this.httpHeaders, that.httpHeaders) - && Objects.equals(this.cacheOptions, that.cacheOptions); + && Objects.equals(this.cacheOptions, that.cacheOptions) + && Objects.equals(this.citationDocuments, that.citationDocuments); } @Override public int hashCode() { return Objects.hash(this.model, this.maxTokens, this.metadata, this.stopSequences, this.temperature, this.topP, this.topK, this.toolChoice, this.thinking, this.toolCallbacks, this.toolNames, - this.internalToolExecutionEnabled, this.toolContext, this.httpHeaders, this.cacheOptions); + this.internalToolExecutionEnabled, this.toolContext, this.httpHeaders, this.cacheOptions, + this.citationDocuments); } public static final class Builder { @@ -425,7 +469,40 @@ public Builder cacheOptions(AnthropicCacheOptions cacheOptions) { return this; } + /** + * Set citation documents for the request. + * @param citationDocuments List of documents to include for citations + * @return Builder for method chaining + */ + public Builder citationDocuments(List citationDocuments) { + this.options.setCitationDocuments(citationDocuments); + return this; + } + + /** + * Set citation documents from variable arguments. + * @param documents Variable number of CitationDocument objects + * @return Builder for method chaining + */ + public Builder citationDocuments(CitationDocument... documents) { + Assert.notNull(documents, "Citation documents cannot be null"); + this.options.citationDocuments.addAll(Arrays.asList(documents)); + return this; + } + + /** + * Add a single citation document. + * @param document Citation document to add + * @return Builder for method chaining + */ + public Builder addCitationDocument(CitationDocument document) { + Assert.notNull(document, "Citation document cannot be null"); + this.options.citationDocuments.add(document); + return this; + } + public AnthropicChatOptions build() { + this.options.validateCitationConsistency(); return this.options; } diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/Citation.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/Citation.java new file mode 100644 index 00000000000..1a7a8b06989 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/Citation.java @@ -0,0 +1,223 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.anthropic; + +import org.springframework.ai.anthropic.api.CitationDocument; + +/** + * Represents a citation reference in a Claude response. Citations indicate which parts of + * the provided documents were referenced when generating the response. + * + *

+ * Citations are returned in the response metadata under the "citations" key and include: + *

    + *
  • The cited text from the document
  • + *
  • The document index (which document was cited)
  • + *
  • The document title (if provided)
  • + *
  • Location information (character ranges, page numbers, or content block + * indices)
  • + *
+ * + *

Citation Types

+ *
    + *
  • CHAR_LOCATION: For plain text documents, includes character start/end + * indices
  • + *
  • PAGE_LOCATION: For PDF documents, includes page start/end numbers
  • + *
  • CONTENT_BLOCK_LOCATION: For custom content documents, includes block + * start/end indices
  • + *
+ * + *

Example Usage

+ * + *
{@code
+ * ChatResponse response = chatModel.call(prompt);
+ *
+ * List citations = (List) response.getMetadata().get("citations");
+ *
+ * for (Citation citation : citations) {
+ *     System.out.println("Document: " + citation.getDocumentTitle());
+ *     System.out.println("Location: " + citation.getLocationDescription());
+ *     System.out.println("Text: " + citation.getCitedText());
+ * }
+ * }
+ * + * @author Soby Chacko + * @since 1.1.0 + * @see CitationDocument + */ +public final class Citation { + + /** + * Types of citation locations based on document format. + */ + public enum LocationType { + + /** Character-based location for plain text documents */ + CHAR_LOCATION, + + /** Page-based location for PDF documents */ + PAGE_LOCATION, + + /** Block-based location for custom content documents */ + CONTENT_BLOCK_LOCATION + + } + + private final LocationType type; + + private final String citedText; + + private final int documentIndex; + + private final String documentTitle; + + // Location-specific fields + private Integer startCharIndex; + + private Integer endCharIndex; + + private Integer startPageNumber; + + private Integer endPageNumber; + + private Integer startBlockIndex; + + private Integer endBlockIndex; + + // Private constructor + private Citation(LocationType type, String citedText, int documentIndex, String documentTitle) { + this.type = type; + this.citedText = citedText; + this.documentIndex = documentIndex; + this.documentTitle = documentTitle; + } + + /** + * Create a character location citation for plain text documents. + * @param citedText the text that was cited from the document + * @param documentIndex the index of the document (0-based) + * @param documentTitle the title of the document + * @param startCharIndex the starting character index (0-based, inclusive) + * @param endCharIndex the ending character index (exclusive) + * @return a new Citation with CHAR_LOCATION type + */ + public static Citation ofCharLocation(String citedText, int documentIndex, String documentTitle, int startCharIndex, + int endCharIndex) { + Citation citation = new Citation(LocationType.CHAR_LOCATION, citedText, documentIndex, documentTitle); + citation.startCharIndex = startCharIndex; + citation.endCharIndex = endCharIndex; + return citation; + } + + /** + * Create a page location citation for PDF documents. + * @param citedText the text that was cited from the document + * @param documentIndex the index of the document (0-based) + * @param documentTitle the title of the document + * @param startPageNumber the starting page number (1-based, inclusive) + * @param endPageNumber the ending page number (exclusive) + * @return a new Citation with PAGE_LOCATION type + */ + public static Citation ofPageLocation(String citedText, int documentIndex, String documentTitle, + int startPageNumber, int endPageNumber) { + Citation citation = new Citation(LocationType.PAGE_LOCATION, citedText, documentIndex, documentTitle); + citation.startPageNumber = startPageNumber; + citation.endPageNumber = endPageNumber; + return citation; + } + + /** + * Create a content block location citation for custom content documents. + * @param citedText the text that was cited from the document + * @param documentIndex the index of the document (0-based) + * @param documentTitle the title of the document + * @param startBlockIndex the starting content block index (0-based, inclusive) + * @param endBlockIndex the ending content block index (exclusive) + * @return a new Citation with CONTENT_BLOCK_LOCATION type + */ + public static Citation ofContentBlockLocation(String citedText, int documentIndex, String documentTitle, + int startBlockIndex, int endBlockIndex) { + Citation citation = new Citation(LocationType.CONTENT_BLOCK_LOCATION, citedText, documentIndex, documentTitle); + citation.startBlockIndex = startBlockIndex; + citation.endBlockIndex = endBlockIndex; + return citation; + } + + public LocationType getType() { + return this.type; + } + + public String getCitedText() { + return this.citedText; + } + + public int getDocumentIndex() { + return this.documentIndex; + } + + public String getDocumentTitle() { + return this.documentTitle; + } + + public Integer getStartCharIndex() { + return this.startCharIndex; + } + + public Integer getEndCharIndex() { + return this.endCharIndex; + } + + public Integer getStartPageNumber() { + return this.startPageNumber; + } + + public Integer getEndPageNumber() { + return this.endPageNumber; + } + + public Integer getStartBlockIndex() { + return this.startBlockIndex; + } + + public Integer getEndBlockIndex() { + return this.endBlockIndex; + } + + /** + * Get a human-readable location description. + */ + public String getLocationDescription() { + return switch (this.type) { + case CHAR_LOCATION -> String.format("Characters %d-%d", this.startCharIndex, this.endCharIndex); + case PAGE_LOCATION -> + this.startPageNumber.equals(this.endPageNumber - 1) ? String.format("Page %d", this.startPageNumber) + : String.format("Pages %d-%d", this.startPageNumber, this.endPageNumber - 1); + case CONTENT_BLOCK_LOCATION -> + this.startBlockIndex.equals(this.endBlockIndex - 1) ? String.format("Block %d", this.startBlockIndex) + : String.format("Blocks %d-%d", this.startBlockIndex, this.endBlockIndex - 1); + }; + } + + @Override + public String toString() { + return String.format("Citation{type=%s, documentIndex=%d, documentTitle='%s', location='%s', citedText='%s'}", + this.type, this.documentIndex, this.documentTitle, getLocationDescription(), + this.citedText != null && this.citedText.length() > 50 ? this.citedText.substring(0, 50) + "..." + : this.citedText); + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java index cdd1a4fef51..cc18d7cb717 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java @@ -753,6 +753,53 @@ public record AnthropicMessage( // @formatter:on } + /** + * Citations configuration for document ContentBlocks. + */ + @JsonInclude(Include.NON_NULL) + public record CitationsConfig(@JsonProperty("enabled") Boolean enabled) { + } + + /** + * Citation response structure from Anthropic API. Maps to the actual API response + * format for citations. Contains location information that varies by document type: + * character indices for plain text, page numbers for PDFs, or content block indices + * for custom content. + * + * @param type The citation location type ("char_location", "page_location", or + * "content_block_location") + * @param citedText The text that was cited from the document + * @param documentIndex The index of the document that was cited (0-based) + * @param documentTitle The title of the document that was cited + * @param startCharIndex The starting character index for "char_location" type + * (0-based, inclusive) + * @param endCharIndex The ending character index for "char_location" type (exclusive) + * @param startPageNumber The starting page number for "page_location" type (1-based, + * inclusive) + * @param endPageNumber The ending page number for "page_location" type (exclusive) + * @param startBlockIndex The starting content block index for + * "content_block_location" type (0-based, inclusive) + * @param endBlockIndex The ending content block index for "content_block_location" + * type (exclusive) + */ + @JsonInclude(Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CitationResponse(@JsonProperty("type") String type, @JsonProperty("cited_text") String citedText, + @JsonProperty("document_index") Integer documentIndex, @JsonProperty("document_title") String documentTitle, + + // For char_location type + @JsonProperty("start_char_index") Integer startCharIndex, + @JsonProperty("end_char_index") Integer endCharIndex, + + // For page_location type + @JsonProperty("start_page_number") Integer startPageNumber, + @JsonProperty("end_page_number") Integer endPageNumber, + + // For content_block_location type + @JsonProperty("start_block_index") Integer startBlockIndex, + @JsonProperty("end_block_index") Integer endBlockIndex) { + } + /** * The content block of the message. * @@ -797,7 +844,12 @@ public record ContentBlock( @JsonProperty("data") String data, // cache object - @JsonProperty("cache_control") CacheControl cacheControl + @JsonProperty("cache_control") CacheControl cacheControl, + + // Citation fields + @JsonProperty("title") String title, + @JsonProperty("context") String context, + @JsonProperty("citations") Object citations // Can be CitationsConfig for requests or List for responses ) { // @formatter:on @@ -816,7 +868,7 @@ public ContentBlock(String mediaType, String data) { * @param source The source of the content. */ public ContentBlock(Type type, Source source) { - this(type, source, null, null, null, null, null, null, null, null, null, null, null); + this(type, source, null, null, null, null, null, null, null, null, null, null, null, null, null, null); } /** @@ -824,7 +876,8 @@ public ContentBlock(Type type, Source source) { * @param source The source of the content. */ public ContentBlock(Source source) { - this(Type.IMAGE, source, null, null, null, null, null, null, null, null, null, null, null); + this(Type.IMAGE, source, null, null, null, null, null, null, null, null, null, null, null, null, null, + null); } /** @@ -832,11 +885,11 @@ public ContentBlock(Source source) { * @param text The text of the content. */ public ContentBlock(String text) { - this(Type.TEXT, null, text, null, null, null, null, null, null, null, null, null, null); + this(Type.TEXT, null, text, null, null, null, null, null, null, null, null, null, null, null, null, null); } public ContentBlock(String text, CacheControl cache) { - this(Type.TEXT, null, text, null, null, null, null, null, null, null, null, null, cache); + this(Type.TEXT, null, text, null, null, null, null, null, null, null, null, null, cache, null, null, null); } // Tool result @@ -847,7 +900,8 @@ public ContentBlock(String text, CacheControl cache) { * @param content The content of the tool result. */ public ContentBlock(Type type, String toolUseId, String content) { - this(type, null, null, null, null, null, null, toolUseId, content, null, null, null, null); + this(type, null, null, null, null, null, null, toolUseId, content, null, null, null, null, null, null, + null); } /** @@ -858,7 +912,7 @@ public ContentBlock(Type type, String toolUseId, String content) { * @param index The index of the content block. */ public ContentBlock(Type type, Source source, String text, Integer index) { - this(type, source, text, index, null, null, null, null, null, null, null, null, null); + this(type, source, text, index, null, null, null, null, null, null, null, null, null, null, null, null); } // Tool use input JSON delta streaming @@ -870,7 +924,21 @@ public ContentBlock(Type type, Source source, String text, Integer index) { * @param input The input of the tool use. */ public ContentBlock(Type type, String id, String name, Map input) { - this(type, null, null, null, id, name, input, null, null, null, null, null, null); + this(type, null, null, null, id, name, input, null, null, null, null, null, null, null, null, null); + } + + /** + * Create a document ContentBlock with citations and optional caching. + * @param source The document source + * @param title Optional document title + * @param context Optional document context + * @param citationsEnabled Whether citations are enabled + * @param cacheControl Optional cache control (can be null) + */ + public ContentBlock(Source source, String title, String context, boolean citationsEnabled, + CacheControl cacheControl) { + this(Type.DOCUMENT, source, null, null, null, null, null, null, null, null, null, null, cacheControl, title, + context, citationsEnabled ? new CitationsConfig(true) : null); } public static ContentBlockBuilder from(ContentBlock contentBlock) { @@ -983,7 +1051,8 @@ public record Source( @JsonProperty("type") String type, @JsonProperty("media_type") String mediaType, @JsonProperty("data") String data, - @JsonProperty("url") String url) { + @JsonProperty("url") String url, + @JsonProperty("content") List content) { // @formatter:on /** @@ -992,11 +1061,15 @@ public record Source( * @param data The content data. */ public Source(String mediaType, String data) { - this("base64", mediaType, data, null); + this("base64", mediaType, data, null, null); } public Source(String url) { - this("url", null, null, url); + this("url", null, null, url, null); + } + + public Source(List content) { + this("content", null, null, null, content); } } @@ -1029,6 +1102,12 @@ public static class ContentBlockBuilder { private CacheControl cacheControl; + private String title; + + private String context; + + private Object citations; + public ContentBlockBuilder(ContentBlock contentBlock) { this.type = contentBlock.type; this.source = contentBlock.source; @@ -1043,6 +1122,9 @@ public ContentBlockBuilder(ContentBlock contentBlock) { this.thinking = contentBlock.thinking; this.data = contentBlock.data; this.cacheControl = contentBlock.cacheControl; + this.title = contentBlock.title; + this.context = contentBlock.context; + this.citations = contentBlock.citations; } public ContentBlockBuilder type(Type type) { @@ -1112,7 +1194,8 @@ public ContentBlockBuilder cacheControl(CacheControl cacheControl) { public ContentBlock build() { return new ContentBlock(this.type, this.source, this.text, this.index, this.id, this.name, this.input, - this.toolUseId, this.content, this.signature, this.thinking, this.data, this.cacheControl); + this.toolUseId, this.content, this.signature, this.thinking, this.data, this.cacheControl, + this.title, this.context, this.citations); } } diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/CitationDocument.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/CitationDocument.java new file mode 100644 index 00000000000..c5da01b3a16 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/CitationDocument.java @@ -0,0 +1,286 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.anthropic.api; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.Citation; +import org.springframework.util.Assert; + +/** + * Builder class for creating citation-enabled documents. Provides a fluent API for + * constructing documents of different types that can be converted to ContentBlocks for + * the Anthropic API. + * + *

+ * Citations allow Claude to reference specific parts of provided documents in its + * responses. When a citation document is included in a prompt, Claude can cite the source + * material, and citation metadata (character ranges, page numbers, or content blocks) is + * returned in the response. + * + *

Usage Examples

+ * + *

+ * Plain Text Document: + * + *

{@code
+ * CitationDocument document = CitationDocument.builder()
+ *     .plainText("The Eiffel Tower was completed in 1889 in Paris, France.")
+ *     .title("Eiffel Tower Facts")
+ *     .build();
+ *
+ * AnthropicChatOptions options = AnthropicChatOptions.builder()
+ *     .model(AnthropicApi.ChatModel.CLAUDE_3_7_SONNET.getName())
+ *     .citationDocuments(document)
+ *     .build();
+ *
+ * ChatResponse response = chatModel.call(new Prompt("When was the Eiffel Tower built?", options));
+ *
+ * // Citations are available in response metadata
+ * List citations = (List) response.getMetadata().get("citations");
+ * }
+ * + *

+ * PDF Document: + * + *

{@code
+ * CitationDocument document = CitationDocument.builder()
+ *     .pdfFile("path/to/document.pdf")
+ *     .title("Technical Specification")
+ *     .build();
+ *
+ * // PDF citations include page numbers
+ * }
+ * + *

+ * Custom Content Blocks: + * + *

{@code
+ * CitationDocument document = CitationDocument.builder()
+ *     .customContent(
+ *         "Fact 1: The Great Wall spans 21,196 kilometers.",
+ *         "Fact 2: Construction began in the 7th century BC.",
+ *         "Fact 3: It was built to protect Chinese states."
+ *     )
+ *     .title("Great Wall Facts")
+ *     .build();
+ *
+ * // Custom content citations reference specific content blocks
+ * }
+ * + * @author Soby Chacko + * @since 1.1.0 + * @see Citation + * @see AnthropicChatOptions#getCitationDocuments() + */ +public final class CitationDocument { + + /** + * Document types supported by Anthropic Citations API. Each type uses different + * citation location formats in the response. + */ + public enum DocumentType { + + /** + * Plain text document with character-based citations. Text is automatically + * chunked by sentences and citations return character start/end indices. + */ + PLAIN_TEXT, + + /** + * PDF document with page-based citations. Content is extracted and chunked from + * the PDF, and citations return page start/end numbers. + */ + PDF, + + /** + * Custom content with user-defined blocks and block-based citations. Content is + * provided as explicit blocks, and citations return block start/end indices. + */ + CUSTOM_CONTENT + + } + + private DocumentType type; + + private String title; + + private String context; + + private Object sourceData; // String for text, byte[] for PDF, List for + // custom + + private boolean citationsEnabled = false; + + private CitationDocument() { + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Convert this CitationDocument to a ContentBlock for API usage. + * @return ContentBlock configured for citations + */ + public AnthropicApi.ContentBlock toContentBlock() { + AnthropicApi.ContentBlock.Source source = createSource(); + return new AnthropicApi.ContentBlock(source, this.title, this.context, this.citationsEnabled, null); + } + + private AnthropicApi.ContentBlock.Source createSource() { + return switch (this.type) { + case PLAIN_TEXT -> + new AnthropicApi.ContentBlock.Source("text", "text/plain", (String) this.sourceData, null, null); + case PDF -> { + String base64Data = Base64.getEncoder().encodeToString((byte[]) this.sourceData); + yield new AnthropicApi.ContentBlock.Source("base64", "application/pdf", base64Data, null, null); + } + case CUSTOM_CONTENT -> { + @SuppressWarnings("unchecked") + List content = (List) this.sourceData; + yield new AnthropicApi.ContentBlock.Source("content", null, null, null, content); + } + }; + } + + public boolean isCitationsEnabled() { + return this.citationsEnabled; + } + + /** + * Builder class for CitationDocument. + */ + public static class Builder { + + private final CitationDocument document = new CitationDocument(); + + /** + * Create a plain text document. + * @param text The document text content + * @return Builder for method chaining + */ + public Builder plainText(String text) { + Assert.hasText(text, "Text content cannot be null or empty"); + this.document.type = DocumentType.PLAIN_TEXT; + this.document.sourceData = text; + return this; + } + + /** + * Create a PDF document from byte array. + * @param pdfBytes The PDF file content as bytes + * @return Builder for method chaining + */ + public Builder pdf(byte[] pdfBytes) { + Assert.notNull(pdfBytes, "PDF bytes cannot be null"); + Assert.isTrue(pdfBytes.length > 0, "PDF bytes cannot be empty"); + this.document.type = DocumentType.PDF; + this.document.sourceData = pdfBytes; + return this; + } + + /** + * Create a PDF document from file path. + * @param filePath Path to the PDF file + * @return Builder for method chaining + * @throws IOException if file cannot be read + */ + public Builder pdfFile(String filePath) throws IOException { + Assert.hasText(filePath, "File path cannot be null or empty"); + byte[] pdfBytes = Files.readAllBytes(Paths.get(filePath)); + return pdf(pdfBytes); + } + + /** + * Create a custom content document with user-defined blocks. + * @param contentBlocks List of content blocks for fine-grained citation control + * @return Builder for method chaining + */ + public Builder customContent(List contentBlocks) { + Assert.notNull(contentBlocks, "Content blocks cannot be null"); + Assert.notEmpty(contentBlocks, "Content blocks cannot be empty"); + this.document.type = DocumentType.CUSTOM_CONTENT; + this.document.sourceData = new ArrayList<>(contentBlocks); + return this; + } + + /** + * Create a custom content document from text blocks. + * @param textBlocks Variable number of text strings to create content blocks + * @return Builder for method chaining + */ + public Builder customContent(String... textBlocks) { + Assert.notNull(textBlocks, "Text blocks cannot be null"); + Assert.notEmpty(textBlocks, "Text blocks cannot be empty"); + List blocks = Arrays.stream(textBlocks) + .map(AnthropicApi.ContentBlock::new) + .collect(Collectors.toList()); + return customContent(blocks); + } + + /** + * Set the document title (optional, not included in citations). + * @param title Document title for reference + * @return Builder for method chaining + */ + public Builder title(String title) { + this.document.title = title; + return this; + } + + /** + * Set the document context (optional, not included in citations). + * @param context Additional context or metadata about the document + * @return Builder for method chaining + */ + public Builder context(String context) { + this.document.context = context; + return this; + } + + /** + * Enable or disable citations for this document. + * @param enabled Whether citations should be enabled + * @return Builder for method chaining + */ + public Builder citationsEnabled(boolean enabled) { + this.document.citationsEnabled = enabled; + return this; + } + + /** + * Build the CitationDocument. + * @return Configured CitationDocument + */ + public CitationDocument build() { + Assert.notNull(this.document.type, "Document type must be specified"); + Assert.notNull(this.document.sourceData, "Document source data must be specified"); + return this.document; + } + + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/StreamHelper.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/StreamHelper.java index 2898a7b7aad..fbb26705450 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/StreamHelper.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/StreamHelper.java @@ -164,7 +164,8 @@ else if (event.type().equals(EventType.CONTENT_BLOCK_START)) { } else if (contentBlockStartEvent.contentBlock() instanceof ContentBlockThinking thinkingBlock) { ContentBlock cb = new ContentBlock(Type.THINKING, null, null, contentBlockStartEvent.index(), null, - null, null, null, null, thinkingBlock.signature(), thinkingBlock.thinking(), null, null); + null, null, null, null, thinkingBlock.signature(), thinkingBlock.thinking(), null, null, null, + null, null); contentBlockReference.get().withType(event.type().name()).withContent(List.of(cb)); } else { @@ -181,12 +182,12 @@ else if (event.type().equals(EventType.CONTENT_BLOCK_DELTA)) { } else if (contentBlockDeltaEvent.delta() instanceof ContentBlockDeltaThinking thinking) { ContentBlock cb = new ContentBlock(Type.THINKING_DELTA, null, null, contentBlockDeltaEvent.index(), - null, null, null, null, null, null, thinking.thinking(), null, null); + null, null, null, null, null, null, thinking.thinking(), null, null, null, null, null); contentBlockReference.get().withType(event.type().name()).withContent(List.of(cb)); } else if (contentBlockDeltaEvent.delta() instanceof ContentBlockDeltaSignature sig) { ContentBlock cb = new ContentBlock(Type.SIGNATURE_DELTA, null, null, contentBlockDeltaEvent.index(), - null, null, null, null, null, sig.signature(), null, null, null); + null, null, null, null, null, sig.signature(), null, null, null, null, null, null); contentBlockReference.get().withType(event.type().name()).withContent(List.of(cb)); } else { diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicCitationIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicCitationIT.java new file mode 100644 index 00000000000..092455733ed --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicCitationIT.java @@ -0,0 +1,301 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.anthropic; + +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.anthropic.api.CitationDocument; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Anthropic Citations API support. + * + * @author Soby Chacko + * @since 1.1.0 + */ +@SpringBootTest(classes = AnthropicCitationIT.Config.class) +@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+") +class AnthropicCitationIT { + + private static final Logger logger = LoggerFactory.getLogger(AnthropicCitationIT.class); + + @Autowired + private AnthropicChatModel chatModel; + + @Test + void testPlainTextCitation() { + // Create a citation document with plain text + CitationDocument document = CitationDocument.builder() + .plainText( + "The Eiffel Tower is located in Paris, France. It was completed in 1889 and stands 330 meters tall.") + .title("Eiffel Tower Facts") + .citationsEnabled(true) + .build(); + + // Create a prompt asking a question about the document + // Use explicit instruction to answer from the provided document + UserMessage userMessage = new UserMessage( + "Based solely on the provided document, where is the Eiffel Tower located and when was it completed?"); + + AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_3_7_SONNET.getName()) + .maxTokens(2048) + .temperature(0.0) // Use temperature 0 for more deterministic responses + .citationDocuments(document) + .build(); + + Prompt prompt = new Prompt(List.of(userMessage), options); + + // Call the model + ChatResponse response = this.chatModel.call(prompt); + + // Verify response exists and is not empty + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotEmpty(); + String responseText = response.getResult().getOutput().getText(); + assertThat(responseText).as("Response text should not be blank").isNotBlank(); + + // Verify citations are present in metadata (this is the core feature being + // tested) + Object citationsObj = response.getMetadata().get("citations"); + assertThat(citationsObj).as("Citations should be present in response metadata").isNotNull(); + + @SuppressWarnings("unchecked") + List citations = (List) citationsObj; + assertThat(citations).as("Citation list should not be empty").isNotEmpty(); + + // Verify citation structure - all citations should have proper fields + for (Citation citation : citations) { + assertThat(citation.getType()).as("Citation type should be CHAR_LOCATION for plain text") + .isEqualTo(Citation.LocationType.CHAR_LOCATION); + assertThat(citation.getCitedText()).as("Cited text should not be blank").isNotBlank(); + assertThat(citation.getDocumentIndex()).as("Document index should be 0 (first document)").isEqualTo(0); + assertThat(citation.getDocumentTitle()).as("Document title should match").isEqualTo("Eiffel Tower Facts"); + assertThat(citation.getStartCharIndex()).as("Start char index should be non-negative") + .isGreaterThanOrEqualTo(0); + assertThat(citation.getEndCharIndex()).as("End char index should be greater than start") + .isGreaterThan(citation.getStartCharIndex()); + } + } + + @Test + void testMultipleCitationDocuments() { + // Create multiple citation documents + CitationDocument parisDoc = CitationDocument.builder() + .plainText("Paris is the capital city of France. It has a population of about 2.1 million people.") + .title("Paris Information") + .citationsEnabled(true) + .build(); + + CitationDocument eiffelDoc = CitationDocument.builder() + .plainText("The Eiffel Tower was designed by Gustave Eiffel and completed in 1889 for the World's Fair.") + .title("Eiffel Tower History") + .citationsEnabled(true) + .build(); + + // Use explicit instruction to answer from the provided documents + UserMessage userMessage = new UserMessage( + "Based solely on the provided documents, what is the capital of France and who designed the Eiffel Tower?"); + + AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_3_7_SONNET.getName()) + .maxTokens(1024) + .temperature(0.0) // Use temperature 0 for more deterministic responses + .citationDocuments(parisDoc, eiffelDoc) + .build(); + + Prompt prompt = new Prompt(List.of(userMessage), options); + + // Call the model + ChatResponse response = this.chatModel.call(prompt); + + // Verify response exists and is not empty + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotEmpty(); + String responseText = response.getResult().getOutput().getText(); + assertThat(responseText).as("Response text should not be blank").isNotBlank(); + + // Verify citations are present (this is the core feature being tested) + Object citationsObj = response.getMetadata().get("citations"); + assertThat(citationsObj).as("Citations should be present in response metadata").isNotNull(); + + @SuppressWarnings("unchecked") + List citations = (List) citationsObj; + assertThat(citations).as("Citation list should not be empty").isNotEmpty(); + + // Verify we have citations from both documents + // Check that citations reference both document indices (0 and 1) + boolean hasDoc0 = citations.stream().anyMatch(c -> c.getDocumentIndex() == 0); + boolean hasDoc1 = citations.stream().anyMatch(c -> c.getDocumentIndex() == 1); + assertThat(hasDoc0 && hasDoc1).as("Should have citations from at least one document").isTrue(); + + // Verify citation structure for all citations + for (Citation citation : citations) { + assertThat(citation.getType()).as("Citation type should be CHAR_LOCATION for plain text") + .isEqualTo(Citation.LocationType.CHAR_LOCATION); + assertThat(citation.getCitedText()).as("Cited text should not be blank").isNotBlank(); + assertThat(citation.getDocumentIndex()).as("Document index should be 0 or 1").isIn(0, 1); + assertThat(citation.getDocumentTitle()).as("Document title should be one of the provided titles") + .isIn("Paris Information", "Eiffel Tower History"); + assertThat(citation.getStartCharIndex()).as("Start char index should be non-negative") + .isGreaterThanOrEqualTo(0); + assertThat(citation.getEndCharIndex()).as("End char index should be greater than start") + .isGreaterThan(citation.getStartCharIndex()); + } + } + + @Test + void testCustomContentCitation() { + // Create a citation document with custom content blocks for fine-grained citation + // control + CitationDocument document = CitationDocument.builder() + .customContent("The Great Wall of China is approximately 21,196 kilometers long.", + "It was built over many centuries, starting in the 7th century BC.", + "The wall was constructed to protect Chinese states from invasions.") + .title("Great Wall Facts") + .citationsEnabled(true) + .build(); + + UserMessage userMessage = new UserMessage( + "Based solely on the provided document, how long is the Great Wall of China and when was it started?"); + + AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_3_7_SONNET.getName()) + .maxTokens(1024) + .temperature(0.0) + .citationDocuments(document) + .build(); + + Prompt prompt = new Prompt(List.of(userMessage), options); + ChatResponse response = this.chatModel.call(prompt); + + // Verify response and citations + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotEmpty(); + assertThat(response.getResult().getOutput().getText()).isNotBlank(); + + Object citationsObj = response.getMetadata().get("citations"); + assertThat(citationsObj).as("Citations should be present in response metadata").isNotNull(); + + @SuppressWarnings("unchecked") + List citations = (List) citationsObj; + assertThat(citations).as("Citation list should not be empty").isNotEmpty(); + + // For custom content, citations should use CONTENT_BLOCK_LOCATION type + for (Citation citation : citations) { + assertThat(citation.getType()).as("Citation type should be CONTENT_BLOCK_LOCATION for custom content") + .isEqualTo(Citation.LocationType.CONTENT_BLOCK_LOCATION); + assertThat(citation.getCitedText()).as("Cited text should not be blank").isNotBlank(); + assertThat(citation.getDocumentIndex()).as("Document index should be 0").isEqualTo(0); + assertThat(citation.getDocumentTitle()).as("Document title should match").isEqualTo("Great Wall Facts"); + // For content block citations, we have start/end block indices instead of + // char indices + assertThat(citation.getStartBlockIndex()).as("Start block index should be non-negative") + .isGreaterThanOrEqualTo(0); + assertThat(citation.getEndBlockIndex()).as("End block index should be >= start") + .isGreaterThanOrEqualTo(citation.getStartBlockIndex()); + } + } + + @Test + void testPdfCitation() throws IOException { + // Load the test PDF from resources + CitationDocument document = CitationDocument.builder() + .pdfFile("src/test/resources/spring-ai-reference-overview.pdf") + .title("Spring AI Reference") + .citationsEnabled(true) + .build(); + + UserMessage userMessage = new UserMessage("Based solely on the provided document, what is Spring AI?"); + + AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_3_7_SONNET.getName()) + .maxTokens(1024) + .temperature(0.0) + .citationDocuments(document) + .build(); + + Prompt prompt = new Prompt(List.of(userMessage), options); + ChatResponse response = this.chatModel.call(prompt); + + // Verify response and citations + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotEmpty(); + assertThat(response.getResult().getOutput().getText()).isNotBlank(); + + Object citationsObj = response.getMetadata().get("citations"); + assertThat(citationsObj).as("Citations should be present for PDF documents").isNotNull(); + + @SuppressWarnings("unchecked") + List citations = (List) citationsObj; + assertThat(citations).as("Citation list should not be empty for PDF").isNotEmpty(); + + // For PDF documents, citations should use PAGE_LOCATION type + for (Citation citation : citations) { + assertThat(citation.getType()).as("Citation type should be PAGE_LOCATION for PDF") + .isEqualTo(Citation.LocationType.PAGE_LOCATION); + assertThat(citation.getCitedText()).as("Cited text should not be blank").isNotBlank(); + assertThat(citation.getDocumentIndex()).as("Document index should be 0").isEqualTo(0); + assertThat(citation.getDocumentTitle()).as("Document title should match").isEqualTo("Spring AI Reference"); + // For page citations, we have start/end page numbers instead of char indices + assertThat(citation.getStartPageNumber()).as("Start page number should be positive").isGreaterThan(0); + assertThat(citation.getEndPageNumber()).as("End page number should be >= start") + .isGreaterThanOrEqualTo(citation.getStartPageNumber()); + } + } + + @SpringBootConfiguration + public static class Config { + + @Bean + public AnthropicApi anthropicApi() { + return AnthropicApi.builder().apiKey(getApiKey()).build(); + } + + private String getApiKey() { + String apiKey = System.getenv("ANTHROPIC_API_KEY"); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalArgumentException( + "You must provide an API key. Put it in an environment variable under the name ANTHROPIC_API_KEY"); + } + return apiKey; + } + + @Bean + public AnthropicChatModel anthropicChatModel(AnthropicApi api) { + return AnthropicChatModel.builder().anthropicApi(api).build(); + } + + } + +} diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc index 55d48c91aa2..ce20b348cda 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc @@ -986,6 +986,398 @@ var userMessage = new UserMessage( var response = this.chatModel.call(new Prompt(List.of(userMessage))); ---- +== Citations + +Anthropic's https://docs.anthropic.com/en/docs/build-with-claude/citations[Citations API] allows Claude to reference specific parts of provided documents when generating responses. +When citation documents are included in a prompt, Claude can cite the source material, and citation metadata (character ranges, page numbers, or content blocks) is returned in the response metadata. + +Citations help improve: + +* **Accuracy verification**: Users can verify Claude's responses against source material +* **Transparency**: See exactly which parts of documents informed the response +* **Compliance**: Meet requirements for source attribution in regulated industries +* **Trust**: Build confidence by showing where information came from + +[NOTE] +==== +*Supported Models* + +Citations are supported on Claude 3.7 Sonnet and Claude 4 models (Opus and Sonnet). + +*Document Types* + +Three types of citation documents are supported: + +* **Plain Text**: Text content with character-level citations +* **PDF**: PDF documents with page-level citations +* **Custom Content**: User-defined content blocks with block-level citations +==== + +=== Creating Citation Documents + +Use the `CitationDocument` builder to create documents that can be cited: + +==== Plain Text Documents + +[source,java] +---- +CitationDocument document = CitationDocument.builder() + .plainText("The Eiffel Tower was completed in 1889 in Paris, France. " + + "It stands 330 meters tall and was designed by Gustave Eiffel.") + .title("Eiffel Tower Facts") + .citationsEnabled(true) + .build(); +---- + +==== PDF Documents + +[source,java] +---- +// From file path +CitationDocument document = CitationDocument.builder() + .pdfFile("path/to/document.pdf") + .title("Technical Specification") + .citationsEnabled(true) + .build(); + +// From byte array +byte[] pdfBytes = loadPdfBytes(); +CitationDocument document = CitationDocument.builder() + .pdf(pdfBytes) + .title("Product Manual") + .citationsEnabled(true) + .build(); +---- + +==== Custom Content Blocks + +For fine-grained citation control, use custom content blocks: + +[source,java] +---- +CitationDocument document = CitationDocument.builder() + .customContent( + "The Great Wall of China is approximately 21,196 kilometers long.", + "It was built over many centuries, starting in the 7th century BC.", + "The wall was constructed to protect Chinese states from invasions." + ) + .title("Great Wall Facts") + .citationsEnabled(true) + .build(); +---- + +=== Using Citations in Requests + +Include citation documents in your chat options: + +[source,java] +---- +ChatResponse response = chatModel.call( + new Prompt( + "When was the Eiffel Tower built and how tall is it?", + AnthropicChatOptions.builder() + .model("claude-3-7-sonnet-latest") + .maxTokens(1024) + .citationDocuments(document) + .build() + ) +); +---- + +==== Multiple Documents + +You can provide multiple documents for Claude to reference: + +[source,java] +---- +CitationDocument parisDoc = CitationDocument.builder() + .plainText("Paris is the capital city of France with a population of 2.1 million.") + .title("Paris Information") + .citationsEnabled(true) + .build(); + +CitationDocument eiffelDoc = CitationDocument.builder() + .plainText("The Eiffel Tower was designed by Gustave Eiffel for the 1889 World's Fair.") + .title("Eiffel Tower History") + .citationsEnabled(true) + .build(); + +ChatResponse response = chatModel.call( + new Prompt( + "What is the capital of France and who designed the Eiffel Tower?", + AnthropicChatOptions.builder() + .model("claude-3-7-sonnet-latest") + .citationDocuments(parisDoc, eiffelDoc) + .build() + ) +); +---- + +=== Accessing Citations + +Citations are returned in the response metadata: + +[source,java] +---- +ChatResponse response = chatModel.call(prompt); + +// Get citations from metadata +@SuppressWarnings("unchecked") +List citations = (List) response.getMetadata().get("citations"); + +// Optional: Get citation count directly from metadata +Integer citationCount = (Integer) response.getMetadata().get("citationCount"); +System.out.println("Total citations: " + citationCount); + +// Process each citation +for (Citation citation : citations) { + System.out.println("Document: " + citation.getDocumentTitle()); + System.out.println("Location: " + citation.getLocationDescription()); + System.out.println("Cited text: " + citation.getCitedText()); + System.out.println("Document index: " + citation.getDocumentIndex()); + System.out.println(); +} +---- + +=== Citation Types + +Citations contain different location information depending on the document type: + +==== Character Location (Plain Text) + +For plain text documents, citations include character indices: + +[source,java] +---- +Citation citation = citations.get(0); +if (citation.getType() == Citation.LocationType.CHAR_LOCATION) { + int start = citation.getStartCharIndex(); + int end = citation.getEndCharIndex(); + String text = citation.getCitedText(); + System.out.println("Characters " + start + "-" + end + ": " + text); +} +---- + +==== Page Location (PDF) + +For PDF documents, citations include page numbers: + +[source,java] +---- +Citation citation = citations.get(0); +if (citation.getType() == Citation.LocationType.PAGE_LOCATION) { + int startPage = citation.getStartPageNumber(); + int endPage = citation.getEndPageNumber(); + System.out.println("Pages " + startPage + "-" + endPage); +} +---- + +==== Content Block Location (Custom Content) + +For custom content, citations reference specific content blocks: + +[source,java] +---- +Citation citation = citations.get(0); +if (citation.getType() == Citation.LocationType.CONTENT_BLOCK_LOCATION) { + int startBlock = citation.getStartBlockIndex(); + int endBlock = citation.getEndBlockIndex(); + System.out.println("Content blocks " + startBlock + "-" + endBlock); +} +---- + +=== Complete Example + +Here's a complete example demonstrating citation usage: + +[source,java] +---- +// Create a citation document +CitationDocument document = CitationDocument.builder() + .plainText("Spring AI is an application framework for AI engineering. " + + "It provides a Spring-friendly API for developing AI applications. " + + "The framework includes abstractions for chat models, embedding models, " + + "and vector databases.") + .title("Spring AI Overview") + .citationsEnabled(true) + .build(); + +// Call the model with the document +ChatResponse response = chatModel.call( + new Prompt( + "What is Spring AI?", + AnthropicChatOptions.builder() + .model("claude-3-7-sonnet-latest") + .maxTokens(1024) + .citationDocuments(document) + .build() + ) +); + +// Display the response +System.out.println("Response: " + response.getResult().getOutput().getText()); +System.out.println("\nCitations:"); + +// Process citations +List citations = (List) response.getMetadata().get("citations"); + +if (citations != null && !citations.isEmpty()) { + for (int i = 0; i < citations.size(); i++) { + Citation citation = citations.get(i); + System.out.println("\n[" + (i + 1) + "] " + citation.getDocumentTitle()); + System.out.println(" Location: " + citation.getLocationDescription()); + System.out.println(" Text: " + citation.getCitedText()); + } +} else { + System.out.println("No citations were provided in the response."); +} +---- + +=== Best Practices + +1. **Use descriptive titles**: Provide meaningful titles for citation documents to help users identify sources in the citations. +2. **Check for null citations**: Not all responses will include citations, so always validate the citations metadata exists before accessing it. +3. **Consider document size**: Larger documents provide more context but consume more input tokens and may affect response time. +4. **Leverage multiple documents**: When answering questions that span multiple sources, provide all relevant documents in a single request rather than making multiple calls. +5. **Use appropriate document types**: Choose plain text for simple content, PDF for existing documents, and custom content blocks when you need fine-grained control over citation granularity. + +=== Real-World Use Cases + +==== Legal Document Analysis + +Analyze contracts and legal documents while maintaining source attribution: + +[source,java] +---- +CitationDocument contract = CitationDocument.builder() + .pdfFile("merger-agreement.pdf") + .title("Merger Agreement 2024") + .citationsEnabled(true) + .build(); + +ChatResponse response = chatModel.call( + new Prompt( + "What are the key termination clauses in this contract?", + AnthropicChatOptions.builder() + .model("claude-sonnet-4") + .maxTokens(2000) + .citationDocuments(contract) + .build() + ) +); + +// Citations will reference specific pages in the PDF +---- + +==== Customer Support Knowledge Base + +Provide accurate customer support answers with verifiable sources: + +[source,java] +---- +CitationDocument kbArticle1 = CitationDocument.builder() + .plainText(loadKnowledgeBaseArticle("authentication")) + .title("Authentication Guide") + .citationsEnabled(true) + .build(); + +CitationDocument kbArticle2 = CitationDocument.builder() + .plainText(loadKnowledgeBaseArticle("billing")) + .title("Billing FAQ") + .citationsEnabled(true) + .build(); + +ChatResponse response = chatModel.call( + new Prompt( + "How do I reset my password and update my billing information?", + AnthropicChatOptions.builder() + .model("claude-3-7-sonnet-latest") + .citationDocuments(kbArticle1, kbArticle2) + .build() + ) +); + +// Citations show which KB articles were referenced +---- + +==== Research and Compliance + +Generate reports that require source citations for compliance: + +[source,java] +---- +CitationDocument clinicalStudy = CitationDocument.builder() + .pdfFile("clinical-trial-results.pdf") + .title("Clinical Trial Phase III Results") + .citationsEnabled(true) + .build(); + +CitationDocument regulatoryGuidance = CitationDocument.builder() + .plainText(loadRegulatoryDocument()) + .title("FDA Guidance Document") + .citationsEnabled(true) + .build(); + +ChatResponse response = chatModel.call( + new Prompt( + "Summarize the efficacy findings and regulatory implications.", + AnthropicChatOptions.builder() + .model("claude-sonnet-4") + .maxTokens(3000) + .citationDocuments(clinicalStudy, regulatoryGuidance) + .build() + ) +); + +// Citations provide audit trail for compliance +---- + +=== Citation Document Options + +==== Context Field + +Optionally provide context about the document that won't be cited but can guide Claude's understanding: + +[source,java] +---- +CitationDocument document = CitationDocument.builder() + .plainText("...") + .title("Legal Contract") + .context("This is a merger agreement dated January 2024 between Company A and Company B") + .build(); +---- + +==== Controlling Citations + +By default, citations are disabled for all documents (opt-in behavior). +To enable citations, explicitly set `citationsEnabled(true)`: + +[source,java] +---- +CitationDocument document = CitationDocument.builder() + .plainText("The Eiffel Tower was completed in 1889...") + .title("Historical Facts") + .citationsEnabled(true) // Explicitly enable citations for this document + .build(); +---- + +You can also provide documents without citations for background context: + +[source,java] +---- +CitationDocument backgroundDoc = CitationDocument.builder() + .plainText("Background information about the industry...") + .title("Context Document") + // citationsEnabled defaults to false - Claude will use this but not cite it + .build(); +---- + +[NOTE] +==== +Anthropic requires consistent citation settings across all documents in a request. +You cannot mix citation-enabled and citation-disabled documents in the same request. +==== + == Sample Controller https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-starter-model-anthropic` to your pom (or gradle) dependencies.