|
44 | 44 | import org.springframework.ai.anthropic.api.AnthropicApi.Role; |
45 | 45 | import org.springframework.ai.anthropic.api.AnthropicCacheOptions; |
46 | 46 | import org.springframework.ai.anthropic.api.AnthropicCacheTtl; |
| 47 | +import org.springframework.ai.anthropic.api.CitationDocument; |
47 | 48 | import org.springframework.ai.anthropic.api.utils.CacheEligibilityResolver; |
48 | 49 | import org.springframework.ai.chat.messages.AssistantMessage; |
49 | 50 | import org.springframework.ai.chat.messages.Message; |
@@ -322,12 +323,13 @@ private ChatResponse toChatResponse(ChatCompletionResponse chatCompletion, Usage |
322 | 323 |
|
323 | 324 | List<Generation> generations = new ArrayList<>(); |
324 | 325 | List<AssistantMessage.ToolCall> toolCalls = new ArrayList<>(); |
| 326 | + CitationContext citationContext = new CitationContext(); |
325 | 327 | for (ContentBlock content : chatCompletion.content()) { |
326 | 328 | switch (content.type()) { |
327 | 329 | case TEXT, TEXT_DELTA: |
328 | | - generations.add(new Generation( |
329 | | - AssistantMessage.builder().content(content.text()).properties(Map.of()).build(), |
330 | | - ChatGenerationMetadata.builder().finishReason(chatCompletion.stopReason()).build())); |
| 330 | + Generation textGeneration = processTextContent(content, chatCompletion.stopReason(), |
| 331 | + citationContext); |
| 332 | + generations.add(textGeneration); |
331 | 333 | break; |
332 | 334 | case THINKING, THINKING_DELTA: |
333 | 335 | Map<String, Object> thinkingProperties = new HashMap<>(); |
@@ -371,7 +373,101 @@ private ChatResponse toChatResponse(ChatCompletionResponse chatCompletion, Usage |
371 | 373 | ChatGenerationMetadata.builder().finishReason(chatCompletion.stopReason()).build()); |
372 | 374 | generations.add(toolCallGeneration); |
373 | 375 | } |
374 | | - return new ChatResponse(generations, this.from(chatCompletion, usage)); |
| 376 | + |
| 377 | + // Create response metadata with citation information if present |
| 378 | + ChatResponseMetadata.Builder metadataBuilder = ChatResponseMetadata.builder() |
| 379 | + .id(chatCompletion.id()) |
| 380 | + .model(chatCompletion.model()) |
| 381 | + .usage(usage) |
| 382 | + .keyValue("stop-reason", chatCompletion.stopReason()) |
| 383 | + .keyValue("stop-sequence", chatCompletion.stopSequence()) |
| 384 | + .keyValue("type", chatCompletion.type()); |
| 385 | + |
| 386 | + // Add citation metadata if citations were found |
| 387 | + if (citationContext.hasCitations()) { |
| 388 | + metadataBuilder.keyValue("citations", citationContext.getAllCitations()) |
| 389 | + .keyValue("citationCount", citationContext.getTotalCitationCount()); |
| 390 | + } |
| 391 | + |
| 392 | + ChatResponseMetadata responseMetadata = metadataBuilder.build(); |
| 393 | + |
| 394 | + return new ChatResponse(generations, responseMetadata); |
| 395 | + } |
| 396 | + |
| 397 | + private Generation processTextContent(ContentBlock content, String stopReason, CitationContext citationContext) { |
| 398 | + // Extract citations if present in the content block |
| 399 | + if (content.citations() instanceof List) { |
| 400 | + try { |
| 401 | + @SuppressWarnings("unchecked") |
| 402 | + List<Object> citationObjects = (List<Object>) content.citations(); |
| 403 | + |
| 404 | + List<Citation> citations = new ArrayList<>(); |
| 405 | + for (Object citationObj : citationObjects) { |
| 406 | + if (citationObj instanceof Map) { |
| 407 | + // Convert Map to CitationResponse using manual parsing |
| 408 | + AnthropicApi.CitationResponse citationResponse = parseCitationFromMap((Map<?, ?>) citationObj); |
| 409 | + citations.add(convertToCitation(citationResponse)); |
| 410 | + } |
| 411 | + else { |
| 412 | + logger.warn("Unexpected citation object type: {}. Expected Map but got: {}. Skipping citation.", |
| 413 | + citationObj.getClass().getName(), citationObj); |
| 414 | + } |
| 415 | + } |
| 416 | + |
| 417 | + if (!citations.isEmpty()) { |
| 418 | + citationContext.addCitations(citations); |
| 419 | + } |
| 420 | + |
| 421 | + } |
| 422 | + catch (Exception e) { |
| 423 | + logger.warn("Failed to parse citations from content block", e); |
| 424 | + } |
| 425 | + } |
| 426 | + |
| 427 | + return new Generation(new AssistantMessage(content.text()), |
| 428 | + ChatGenerationMetadata.builder().finishReason(stopReason).build()); |
| 429 | + } |
| 430 | + |
| 431 | + /** |
| 432 | + * Parse citation data from Map (typically from JSON deserialization). Assumes all |
| 433 | + * required fields are present and of correct types. |
| 434 | + * @param citationMap the map containing citation data from API response |
| 435 | + * @return parsed CitationResponse |
| 436 | + */ |
| 437 | + private AnthropicApi.CitationResponse parseCitationFromMap(Map<?, ?> citationMap) { |
| 438 | + String type = (String) citationMap.get("type"); |
| 439 | + String citedText = (String) citationMap.get("cited_text"); |
| 440 | + Integer documentIndex = (Integer) citationMap.get("document_index"); |
| 441 | + String documentTitle = (String) citationMap.get("document_title"); |
| 442 | + |
| 443 | + Integer startCharIndex = (Integer) citationMap.get("start_char_index"); |
| 444 | + Integer endCharIndex = (Integer) citationMap.get("end_char_index"); |
| 445 | + Integer startPageNumber = (Integer) citationMap.get("start_page_number"); |
| 446 | + Integer endPageNumber = (Integer) citationMap.get("end_page_number"); |
| 447 | + Integer startBlockIndex = (Integer) citationMap.get("start_block_index"); |
| 448 | + Integer endBlockIndex = (Integer) citationMap.get("end_block_index"); |
| 449 | + |
| 450 | + return new AnthropicApi.CitationResponse(type, citedText, documentIndex, documentTitle, startCharIndex, |
| 451 | + endCharIndex, startPageNumber, endPageNumber, startBlockIndex, endBlockIndex); |
| 452 | + } |
| 453 | + |
| 454 | + /** |
| 455 | + * Convert CitationResponse to Citation object. This method handles the conversion to |
| 456 | + * avoid circular dependencies. |
| 457 | + */ |
| 458 | + private Citation convertToCitation(AnthropicApi.CitationResponse citationResponse) { |
| 459 | + return switch (citationResponse.type()) { |
| 460 | + case "char_location" -> Citation.ofCharLocation(citationResponse.citedText(), |
| 461 | + citationResponse.documentIndex(), citationResponse.documentTitle(), |
| 462 | + citationResponse.startCharIndex(), citationResponse.endCharIndex()); |
| 463 | + case "page_location" -> Citation.ofPageLocation(citationResponse.citedText(), |
| 464 | + citationResponse.documentIndex(), citationResponse.documentTitle(), |
| 465 | + citationResponse.startPageNumber(), citationResponse.endPageNumber()); |
| 466 | + case "content_block_location" -> Citation.ofContentBlockLocation(citationResponse.citedText(), |
| 467 | + citationResponse.documentIndex(), citationResponse.documentTitle(), |
| 468 | + citationResponse.startBlockIndex(), citationResponse.endBlockIndex()); |
| 469 | + default -> throw new IllegalArgumentException("Unknown citation type: " + citationResponse.type()); |
| 470 | + }; |
375 | 471 | } |
376 | 472 |
|
377 | 473 | private ChatResponseMetadata from(AnthropicApi.ChatCompletionResponse result) { |
@@ -479,13 +575,22 @@ Prompt buildRequestPrompt(Prompt prompt) { |
479 | 575 | // Merge cache options that are Json-ignored |
480 | 576 | requestOptions.setCacheOptions(runtimeOptions.getCacheOptions() != null ? runtimeOptions.getCacheOptions() |
481 | 577 | : this.defaultOptions.getCacheOptions()); |
| 578 | + |
| 579 | + // Merge citation documents that are Json-ignored |
| 580 | + if (runtimeOptions.getCitationDocuments() != null && !runtimeOptions.getCitationDocuments().isEmpty()) { |
| 581 | + requestOptions.setCitationDocuments(runtimeOptions.getCitationDocuments()); |
| 582 | + } |
| 583 | + else if (this.defaultOptions.getCitationDocuments() != null) { |
| 584 | + requestOptions.setCitationDocuments(this.defaultOptions.getCitationDocuments()); |
| 585 | + } |
482 | 586 | } |
483 | 587 | else { |
484 | 588 | requestOptions.setHttpHeaders(this.defaultOptions.getHttpHeaders()); |
485 | 589 | requestOptions.setInternalToolExecutionEnabled(this.defaultOptions.getInternalToolExecutionEnabled()); |
486 | 590 | requestOptions.setToolNames(this.defaultOptions.getToolNames()); |
487 | 591 | requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks()); |
488 | 592 | requestOptions.setToolContext(this.defaultOptions.getToolContext()); |
| 593 | + requestOptions.setCitationDocuments(this.defaultOptions.getCitationDocuments()); |
489 | 594 | } |
490 | 595 |
|
491 | 596 | ToolCallingChatOptions.validateToolCallbacks(requestOptions.getToolCallbacks()); |
@@ -610,12 +715,24 @@ private List<AnthropicMessage> buildMessages(Prompt prompt, CacheEligibilityReso |
610 | 715 | } |
611 | 716 | } |
612 | 717 |
|
| 718 | + // Get citation documents from options |
| 719 | + List<CitationDocument> citationDocuments = null; |
| 720 | + if (prompt.getOptions() instanceof AnthropicChatOptions anthropicOptions) { |
| 721 | + citationDocuments = anthropicOptions.getCitationDocuments(); |
| 722 | + } |
| 723 | + |
613 | 724 | List<AnthropicMessage> result = new ArrayList<>(); |
614 | 725 | for (int i = 0; i < allMessages.size(); i++) { |
615 | 726 | Message message = allMessages.get(i); |
616 | 727 | MessageType messageType = message.getMessageType(); |
617 | 728 | if (messageType == MessageType.USER) { |
618 | 729 | List<ContentBlock> contentBlocks = new ArrayList<>(); |
| 730 | + // Add citation documents to the FIRST user message only |
| 731 | + if (i == 0 && citationDocuments != null && !citationDocuments.isEmpty()) { |
| 732 | + for (CitationDocument doc : citationDocuments) { |
| 733 | + contentBlocks.add(doc.toContentBlock()); |
| 734 | + } |
| 735 | + } |
619 | 736 | String content = message.getText(); |
620 | 737 | // For conversation history caching, apply cache control to the |
621 | 738 | // last user message to cache the entire conversation up to that point. |
@@ -825,4 +942,30 @@ public AnthropicChatModel build() { |
825 | 942 |
|
826 | 943 | } |
827 | 944 |
|
| 945 | + /** |
| 946 | + * Context object for tracking citations during response processing. Aggregates |
| 947 | + * citations from multiple content blocks in a single response. |
| 948 | + */ |
| 949 | + class CitationContext { |
| 950 | + |
| 951 | + private final List<Citation> allCitations = new ArrayList<>(); |
| 952 | + |
| 953 | + public void addCitations(List<Citation> citations) { |
| 954 | + this.allCitations.addAll(citations); |
| 955 | + } |
| 956 | + |
| 957 | + public boolean hasCitations() { |
| 958 | + return !this.allCitations.isEmpty(); |
| 959 | + } |
| 960 | + |
| 961 | + public List<Citation> getAllCitations() { |
| 962 | + return new ArrayList<>(this.allCitations); |
| 963 | + } |
| 964 | + |
| 965 | + public int getTotalCitationCount() { |
| 966 | + return this.allCitations.size(); |
| 967 | + } |
| 968 | + |
| 969 | + } |
| 970 | + |
828 | 971 | } |
0 commit comments