diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.test.tsx index 57c048b4b..d231cc011 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.test.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.test.tsx @@ -2,10 +2,28 @@ import { cooldownStore } from '../cooldown.store' import { modalStore } from '../modal.store' import { Chat } from './Chat' import { chatStore } from './chat.store' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' +// Create a fresh QueryClient for each test +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + +// Wrapper component for tests that need React Query +const renderWithQueryClient = (ui: React.ReactElement) => { + const testQueryClient = createTestQueryClient() + return render( + {ui} + ) +} + // Mock only external HTTP calls - fetchEventSource makes the actual API request jest.mock('@microsoft/fetch-event-source', () => ({ fetchEventSource: jest.fn(), @@ -103,7 +121,7 @@ describe('Chat Component', () => { setupMessages() // Act - render() + renderWithQueryClient() // Assert - real messages should be rendered expect( @@ -124,7 +142,7 @@ describe('Chat Component', () => { setupMessages() // Act - render() + renderWithQueryClient() // Assert expect( @@ -138,7 +156,7 @@ describe('Chat Component', () => { const user = userEvent.setup() // Act - render() + renderWithQueryClient() await user.click( screen.getByRole('button', { name: /clear conversation/i }) ) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx index b3f0c63ce..5a71c15e5 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx @@ -2,9 +2,27 @@ import { cooldownStore } from '../cooldown.store' import { ApiError } from '../errorHandling' import { ChatMessage } from './ChatMessage' import { ChatMessage as ChatMessageType } from './chat.store' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' import * as React from 'react' +// Create a fresh QueryClient for each test +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + +// Wrapper component for tests that need React Query +const renderWithQueryClient = (ui: React.ReactElement) => { + const testQueryClient = createTestQueryClient() + return render( + {ui} + ) +} + // Reset cooldown store between tests const resetStores = () => { cooldownStore.setState({ @@ -63,7 +81,7 @@ describe('ChatMessage Component', () => { it('should render AI message with correct content', () => { // Act - render() + renderWithQueryClient() // Assert expect( @@ -75,7 +93,7 @@ describe('ChatMessage Component', () => { it('should show feedback buttons when complete', () => { // Act - render() + renderWithQueryClient() // Assert expect( @@ -92,7 +110,7 @@ describe('ChatMessage Component', () => { it('should have correct data attributes', () => { // Act - render() + renderWithQueryClient() // Assert const messageElement = screen @@ -117,7 +135,7 @@ describe('ChatMessage Component', () => { it('should render streaming content', () => { // Act - render() + renderWithQueryClient() // Assert const messageElement = screen @@ -128,7 +146,7 @@ describe('ChatMessage Component', () => { it('should not show feedback buttons when streaming', () => { // Act - render() + renderWithQueryClient() // Assert expect( @@ -158,7 +176,9 @@ describe('ChatMessage Component', () => { it('should show error callout when status is error', () => { // Act - render() + renderWithQueryClient( + + ) // Assert - error callout should be visible with title expect( @@ -166,11 +186,13 @@ describe('ChatMessage Component', () => { ).toBeInTheDocument() }) - it('should display error guidance for 5xx errors', () => { + it('should display error message for 5xx errors', () => { // Act - render() + renderWithQueryClient( + + ) - // Assert - 5xx errors show generic guidance + // Assert - error guidance should be displayed expect( screen.getByText(/We are unable to process your request/i) ).toBeInTheDocument() @@ -189,7 +211,7 @@ describe('ChatMessage Component', () => { it('should render markdown as HTML', () => { // Act - render() + renderWithQueryClient() // Assert - Bold text should be rendered expect(screen.getByText(/Bold text/)).toBeInTheDocument() diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx index 8e1231325..889710802 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx @@ -6,7 +6,8 @@ import { ApiError } from '../errorHandling' import { AskAiEvent, ChunkEvent, EventTypes } from './AskAiEvent' import { GeneratingStatus } from './GeneratingStatus' import { References } from './RelatedResources' -import { ChatMessage as ChatMessageType } from './chat.store' +import { ChatMessage as ChatMessageType, useConversationId } from './chat.store' +import { useMessageFeedback } from './useMessageFeedback' import { useStatusMinDisplay } from './useStatusMinDisplay' import { EuiButtonIcon, @@ -197,18 +198,28 @@ const computeAiStatus = ( // Action bar for complete AI messages const ActionBar = ({ content, + messageId, onRetry, }: { content: string + messageId: string onRetry?: () => void -}) => ( - - +}) => { + const { euiTheme } = useEuiTheme() + const conversationId = useConversationId() + const { selectedReaction, submitFeedback } = useMessageFeedback( + messageId, + conversationId + ) + return ( +
submitFeedback('thumbsDown')} /> - - submitFeedback('thumbsUp')} /> - - {(copy) => ( @@ -242,13 +255,12 @@ const ActionBar = ({ iconType="copy" size="xs" iconSize="s" + color="text" onClick={copy} /> )} - - {onRetry && ( - + {onRetry && ( - - )} - -) + )} +
+ ) +} export const ChatMessage = ({ message, @@ -443,6 +455,7 @@ export const ChatMessage = ({ diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/chat.store.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/chat.store.ts index 857179f76..415febe4c 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/chat.store.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/chat.store.ts @@ -17,6 +17,7 @@ import { v4 as uuidv4 } from 'uuid' import { create } from 'zustand/react' export type AiProvider = 'AgentBuilder' | 'LlmGateway' +export type Reaction = 'thumbsUp' | 'thumbsDown' export interface ChatMessage { id: string @@ -53,10 +54,13 @@ async function computeSHA256(data: string): Promise { return hashArray.map((b) => ('0' + b.toString(16)).slice(-2)).join('') } +const sentAiMessageIds = new Set() + interface ChatState { chatMessages: ChatMessage[] conversationId: string | null aiProvider: AiProvider + messageFeedback: Record // messageId -> reaction scrollPosition: number actions: { submitQuestion: (question: string) => void @@ -72,6 +76,7 @@ interface ChatState { clearChat: () => void clearNon429Errors: () => void cancelStreaming: () => void + setMessageFeedback: (messageId: string, reaction: Reaction) => void abortMessage: (messageId: string) => void isStreaming: (messageId: string) => boolean setScrollPosition: (position: number) => void @@ -80,8 +85,9 @@ interface ChatState { export const chatStore = create((set, get) => ({ chatMessages: [], - conversationId: null, - aiProvider: 'LlmGateway', + conversationId: null, // Start with null - will be set by backend on first request + aiProvider: 'LlmGateway', // Default to LLM Gateway + messageFeedback: {}, scrollPosition: 0, actions: { submitQuestion: (question: string) => { @@ -152,8 +158,9 @@ export const chatStore = create((set, get) => ({ stream.throttler.clear() stream.controller.abort() } + sentAiMessageIds.clear() activeStreams.clear() - set({ chatMessages: [], conversationId: null }) + set({ chatMessages: [], conversationId: null, messageFeedback: {} }) }, clearNon429Errors: () => { @@ -197,6 +204,15 @@ export const chatStore = create((set, get) => ({ })) }, + setMessageFeedback: (messageId: string, reaction: Reaction) => { + set((state) => ({ + messageFeedback: { + ...state.messageFeedback, + [messageId]: reaction, + }, + })) + }, + abortMessage: (messageId) => { const stream = activeStreams.get(messageId) if (stream) { @@ -410,6 +426,8 @@ export const useAiProvider = () => chatStore((state) => state.aiProvider) export const useChatScrollPosition = () => chatStore((state) => state.scrollPosition) export const useChatActions = () => chatStore((state) => state.actions) +export const useMessageReaction = (messageId: string) => + chatStore((state) => state.messageFeedback[messageId] ?? null) export const useIsStreaming = () => chatStore((state) => { const last = state.chatMessages[state.chatMessages.length - 1] diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts new file mode 100644 index 000000000..076b2fb8b --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts @@ -0,0 +1,113 @@ +import { logWarn } from '../../../telemetry/logging' +import { traceSpan } from '../../../telemetry/tracing' +import { Reaction, useChatActions, useMessageReaction } from './chat.store' +import { useMutation } from '@tanstack/react-query' +import { useCallback, useRef } from 'react' + +export type { Reaction } from './chat.store' + +const DEBOUNCE_MS = 500 + +interface MessageFeedbackRequest { + messageId: string + conversationId: string + reaction: Reaction +} + +interface UseMessageFeedbackReturn { + selectedReaction: Reaction | null + submitFeedback: (reaction: Reaction) => void +} + +const submitFeedbackToApi = async ( + payload: MessageFeedbackRequest +): Promise => { + await traceSpan('submit message-feedback', async (span) => { + span.setAttribute('gen_ai.conversation.id', payload.conversationId) // correlation with backend + span.setAttribute('ask_ai.message.id', payload.messageId) + span.setAttribute('ask_ai.feedback.reaction', payload.reaction) + + const response = await fetch('/docs/_api/v1/ask-ai/message-feedback', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + logWarn('Failed to submit feedback', { + 'http.status_code': response.status, + 'ask_ai.message.id': payload.messageId, + 'ask_ai.feedback.reaction': payload.reaction, + }) + } + }) +} + +export const useMessageFeedback = ( + messageId: string, + conversationId: string | null +): UseMessageFeedbackReturn => { + const selectedReaction = useMessageReaction(messageId) + const { setMessageFeedback } = useChatActions() + const debounceTimeoutRef = useRef | null>( + null + ) + + const mutation = useMutation({ + mutationFn: submitFeedbackToApi, + onError: (error) => { + logWarn('Error submitting feedback', { + 'error.message': + error instanceof Error ? error.message : String(error), + }) + // Don't reset selection on error - user intent was clear + }, + }) + + const submitFeedback = useCallback( + (reaction: Reaction) => { + if (!conversationId) { + logWarn('Cannot submit feedback without conversationId', { + 'ask_ai.message.id': messageId, + }) + return + } + + // Ignore if same reaction already selected + if (selectedReaction === reaction) { + return + } + + // Optimistic update - stored in Zustand so it persists across tab switches + setMessageFeedback(messageId, reaction) + + // Cancel any pending debounced submission + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current) + } + + // Debounce the API call - only send the final selection + debounceTimeoutRef.current = setTimeout(() => { + mutation.mutate({ + messageId, + conversationId, + reaction, + }) + }, DEBOUNCE_MS) + }, + [ + messageId, + conversationId, + selectedReaction, + mutation, + setMessageFeedback, + ] + ) + + return { + selectedReaction, + submitFeedback, + } +} diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiMessageFeedbackRequest.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiMessageFeedbackRequest.cs new file mode 100644 index 000000000..127159ca0 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiMessageFeedbackRequest.cs @@ -0,0 +1,30 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json.Serialization; + +namespace Elastic.Documentation.Api.Core.AskAi; + +/// +/// Request model for submitting feedback on a specific Ask AI message. +/// Using Guid type ensures automatic validation during JSON deserialization. +/// +public record AskAiMessageFeedbackRequest( + Guid MessageId, + Guid ConversationId, + Reaction Reaction +); + +/// +/// The user's reaction to an Ask AI message. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Reaction +{ + [JsonStringEnumMemberName("thumbsUp")] + ThumbsUp, + + [JsonStringEnumMemberName("thumbsDown")] + ThumbsDown +} diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiMessageFeedbackUsecase.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiMessageFeedbackUsecase.cs new file mode 100644 index 000000000..82235e392 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiMessageFeedbackUsecase.cs @@ -0,0 +1,43 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Api.Core.AskAi; + +/// +/// Use case for handling Ask AI message feedback submissions. +/// +public class AskAiMessageFeedbackUsecase( + IAskAiMessageFeedbackGateway feedbackGateway, + ILogger logger) +{ + private static readonly ActivitySource FeedbackActivitySource = new(TelemetryConstants.AskAiFeedbackSourceName); + + public async Task SubmitFeedback(AskAiMessageFeedbackRequest request, string? euid, CancellationToken ctx) + { + using var activity = FeedbackActivitySource.StartActivity("record message-feedback", ActivityKind.Internal); + _ = activity?.SetTag("gen_ai.conversation.id", request.ConversationId); // correlation with chat traces + _ = activity?.SetTag("ask_ai.message.id", request.MessageId); + _ = activity?.SetTag("ask_ai.feedback.reaction", request.Reaction.ToString().ToLowerInvariant()); + // Note: user.euid is automatically added to spans by EuidSpanProcessor + + // MessageId and ConversationId are Guid types, so no sanitization needed + logger.LogInformation( + "Recording message feedback for message {MessageId} in conversation {ConversationId}: {Reaction}", + request.MessageId, + request.ConversationId, + request.Reaction); + + var record = new AskAiMessageFeedbackRecord( + request.MessageId, + request.ConversationId, + request.Reaction, + euid + ); + + await feedbackGateway.RecordFeedbackAsync(record, ctx); + } +} diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs index 43555d174..6febaa9d4 100644 --- a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs @@ -23,23 +23,24 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx) _ = activity?.SetTag("gen_ai.provider.name", streamTransformer.AgentProvider); // agent-builder or llm-gateway _ = activity?.SetTag("gen_ai.agent.id", streamTransformer.AgentId); // docs-agent or docs_assistant if (askAiRequest.ConversationId is not null) - _ = activity?.SetTag("gen_ai.conversation.id", askAiRequest.ConversationId); + _ = activity?.SetTag("gen_ai.conversation.id", askAiRequest.ConversationId.ToString()); var inputMessages = new[] { new InputMessage("user", [new MessagePart("text", askAiRequest.Message)]) }; var inputMessagesJson = JsonSerializer.Serialize(inputMessages, ApiJsonContext.Default.InputMessageArray); _ = activity?.SetTag("gen_ai.input.messages", inputMessagesJson); - logger.LogInformation("AskAI input message: {ask_ai.input.message}", askAiRequest.Message); + var sanitizedMessage = askAiRequest.Message?.Replace("\r", "").Replace("\n", ""); + logger.LogInformation("AskAI input message: <{ask_ai.input.message}>", sanitizedMessage); logger.LogInformation("Streaming AskAI response"); var rawStream = await askAiGateway.AskAi(askAiRequest, ctx); // The stream transformer will handle disposing the activity when streaming completes - var transformedStream = await streamTransformer.TransformAsync(rawStream, askAiRequest.ConversationId, activity, ctx); + var transformedStream = await streamTransformer.TransformAsync(rawStream, askAiRequest.ConversationId?.ToString(), activity, ctx); return transformedStream; } } -public record AskAiRequest(string Message, string? ConversationId) +public record AskAiRequest(string Message, Guid? ConversationId) { public static string SystemPrompt => """ diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/IAskAiMessageFeedbackGateway.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/IAskAiMessageFeedbackGateway.cs new file mode 100644 index 000000000..e3c52da60 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/IAskAiMessageFeedbackGateway.cs @@ -0,0 +1,29 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Api.Core.AskAi; + +/// +/// Gateway interface for recording Ask AI message feedback. +/// Infrastructure implementations may use different storage backends (Elasticsearch, database, etc.) +/// +public interface IAskAiMessageFeedbackGateway +{ + /// + /// Records feedback for a specific Ask AI message. + /// + /// The feedback record to store. + /// Cancellation token. + Task RecordFeedbackAsync(AskAiMessageFeedbackRecord record, CancellationToken ctx); +} + +/// +/// Internal record used to pass message feedback data to the gateway. +/// +public record AskAiMessageFeedbackRecord( + Guid MessageId, + Guid ConversationId, + Reaction Reaction, + string? Euid = null +); diff --git a/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs b/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs index 6e8688c12..93f614b10 100644 --- a/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs +++ b/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs @@ -18,6 +18,8 @@ public record InputMessage(string Role, MessagePart[] Parts); public record OutputMessage(string Role, MessagePart[] Parts, string FinishReason); [JsonSerializable(typeof(AskAiRequest))] +[JsonSerializable(typeof(AskAiMessageFeedbackRequest))] +[JsonSerializable(typeof(Reaction))] [JsonSerializable(typeof(SearchApiRequest))] [JsonSerializable(typeof(SearchApiResponse))] [JsonSerializable(typeof(SearchAggregations))] diff --git a/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs b/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs index e6d8d4bd8..209ba5045 100644 --- a/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs +++ b/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs @@ -37,4 +37,10 @@ public static class TelemetryConstants /// Used to trace cache hits, misses, and performance. /// public const string CacheSourceName = "Elastic.Documentation.Api.Cache"; + + /// + /// ActivitySource name for Ask AI feedback operations. + /// Used to trace feedback submissions. + /// + public const string AskAiFeedbackSourceName = "Elastic.Documentation.Api.AskAiFeedback"; } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs index 456ec990d..f8a744d80 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs @@ -29,10 +29,10 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) var agentBuilderPayload = new AgentBuilderPayload( askAiRequest.Message, "docs-agent", - askAiRequest.ConversationId); + askAiRequest.ConversationId?.ToString()); var requestBody = JsonSerializer.Serialize(agentBuilderPayload, AgentBuilderContext.Default.AgentBuilderPayload); - logger.LogInformation("Sending to Agent Builder with conversation_id: {ConversationId}", askAiRequest.ConversationId ?? "(null - first request)"); + logger.LogInformation("Sending to Agent Builder with conversation_id: \"{ConversationId}\"", askAiRequest.ConversationId?.ToString() ?? "(null - first request)"); var kibanaUrl = await parameterProvider.GetParam("docs-kibana-url", false, ctx); var kibanaApiKey = await parameterProvider.GetParam("docs-kibana-apikey", true, ctx); diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderStreamTransformer.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderStreamTransformer.cs index fd61fc9d9..9eed7d6e0 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderStreamTransformer.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderStreamTransformer.cs @@ -19,12 +19,12 @@ public class AgentBuilderStreamTransformer(ILogger +/// Records Ask AI message feedback to Elasticsearch. +/// +public sealed class ElasticsearchAskAiMessageFeedbackGateway : IAskAiMessageFeedbackGateway, IDisposable +{ + private readonly ElasticsearchClient _client; + private readonly string _indexName; + private readonly ILogger _logger; + private readonly SingleNodePool _nodePool; + private bool _disposed; + + public ElasticsearchAskAiMessageFeedbackGateway( + ElasticsearchOptions elasticsearchOptions, + AppEnvironment appEnvironment, + ILogger logger) + { + _logger = logger; + _indexName = $"ask-ai-message-feedback-{appEnvironment.Current.ToStringFast(true)}"; + + _nodePool = new SingleNodePool(new Uri(elasticsearchOptions.Url.Trim())); + using var clientSettings = new ElasticsearchClientSettings( + _nodePool, + sourceSerializer: (_, settings) => new DefaultSourceSerializer(settings, MessageFeedbackJsonContext.Default) + ) + .DefaultIndex(_indexName) + .Authentication(new ApiKey(elasticsearchOptions.ApiKey)); + _client = new ElasticsearchClient(clientSettings); + } + + public void Dispose() + { + if (_disposed) + return; + + _nodePool.Dispose(); + (_client.Transport as IDisposable)?.Dispose(); + _disposed = true; + } + + public async Task RecordFeedbackAsync(AskAiMessageFeedbackRecord record, CancellationToken ctx) + { + var feedbackId = Guid.NewGuid(); + var document = new MessageFeedbackDocument + { + FeedbackId = feedbackId.ToString(), + MessageId = record.MessageId.ToString(), + ConversationId = record.ConversationId.ToString(), + Reaction = record.Reaction.ToString().ToLowerInvariant(), + Euid = record.Euid, + Timestamp = DateTimeOffset.UtcNow + }; + + _logger.LogDebug("Indexing feedback with ID {FeedbackId} to index {IndexName}", feedbackId, _indexName); + + var response = await _client.IndexAsync(document, idx => idx + .Index(_indexName) + .Id(feedbackId.ToString()), ctx); + + // MessageId and ConversationId are Guid types, so no sanitization needed + if (!response.IsValidResponse) + { + _logger.LogWarning( + "Failed to index message feedback for message {MessageId}: {Error}", + record.MessageId, + response.ElasticsearchServerError?.Error?.Reason ?? "Unknown error"); + } + else + { + _logger.LogInformation( + "Message feedback recorded: {Reaction} for message {MessageId} in conversation {ConversationId}. ES _id: {EsId}, Index: {Index}", + record.Reaction, + record.MessageId, + record.ConversationId, + response.Id, + response.Index); + } + } +} + +internal sealed record MessageFeedbackDocument +{ + [JsonPropertyName("feedback_id")] + public required string FeedbackId { get; init; } + + [JsonPropertyName("message_id")] + public required string MessageId { get; init; } + + [JsonPropertyName("conversation_id")] + public required string ConversationId { get; init; } + + [JsonPropertyName("reaction")] + public required string Reaction { get; init; } + + [JsonPropertyName("euid")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Euid { get; init; } + + [JsonPropertyName("@timestamp")] + public required DateTimeOffset Timestamp { get; init; } +} + +[JsonSerializable(typeof(MessageFeedbackDocument))] +internal sealed partial class MessageFeedbackJsonContext : JsonSerializerContext; diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs index ebdaf8593..160716130 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs @@ -66,7 +66,7 @@ public static LlmGatewayRequest CreateFromRequest(AskAiRequest request) => new ChatInput("user", AskAiRequest.SystemPrompt), new ChatInput("user", request.Message) ], - ThreadId: request.ConversationId ?? Guid.NewGuid().ToString() + ThreadId: request.ConversationId?.ToString() ?? Guid.NewGuid().ToString() ); } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs index be7c6befc..b07dbe3b6 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Search/ElasticsearchGateway.cs @@ -10,6 +10,7 @@ using Elastic.Clients.Elasticsearch.Core.Search; using Elastic.Clients.Elasticsearch.QueryDsl; using Elastic.Clients.Elasticsearch.Serialization; +using Elastic.Documentation.Api.Core; using Elastic.Documentation.Api.Core.Search; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Search; @@ -318,17 +319,15 @@ public async Task SearchImplementation(string query, int pageNumbe if (!response.IsValidResponse) { - _logger.LogWarning("Elasticsearch RRF search response was not valid. Reason: {Reason}", - response.ElasticsearchServerError?.Error?.Reason ?? "Unknown"); + _logger.LogWarning("Elasticsearch response is not valid. Reason: {Reason}", + response.ElasticsearchServerError?.Error.Reason ?? "Unknown"); } - else - _logger.LogInformation("RRF search completed for '{Query}'. Total hits: {TotalHits}", query, response.Total); return ProcessSearchResponse(response); } catch (Exception ex) { - _logger.LogError(ex, "Error occurred during Elasticsearch RRF search for '{Query}'", query); + _logger.LogError(ex, "Error occurred during Elasticsearch search"); throw; } } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs index 971da58ca..73dd2cd09 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs @@ -35,6 +35,16 @@ private static void MapAskAiEndpoint(IEndpointRouteBuilder group) var stream = await askAiUsecase.AskAi(askAiRequest, ctx); await stream.CopyToAsync(context.Response.Body, ctx); }); + + // UUID validation is automatic via Guid type deserialization (returns 400 if invalid) + _ = askAiGroup.MapPost("/message-feedback", async (HttpContext context, AskAiMessageFeedbackRequest request, AskAiMessageFeedbackUsecase feedbackUsecase, Cancel ctx) => + { + // Extract euid cookie for user tracking + _ = context.Request.Cookies.TryGetValue("euid", out var euid); + + await feedbackUsecase.SubmitFeedback(request, euid, ctx); + return Results.NoContent(); + }).DisableAntiforgery(); } private static void MapSearchEndpoint(IEndpointRouteBuilder group) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs b/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs index 26af4ee00..b7a1cf168 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs @@ -37,6 +37,7 @@ public static TracerProviderBuilder AddDocsApiTracing(this TracerProviderBuilder .AddSource(TelemetryConstants.StreamTransformerSourceName) .AddSource(TelemetryConstants.OtlpProxySourceName) .AddSource(TelemetryConstants.CacheSourceName) + .AddSource(TelemetryConstants.AskAiFeedbackSourceName) .AddAspNetCoreInstrumentation(aspNetCoreOptions => { // Don't trace root API endpoint (health check) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs index 7bcbfa3ad..b3ab979ca 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs @@ -216,6 +216,11 @@ private static void AddAskAiUsecase(IServiceCollection services, AppEnv appEnv) _ = services.AddScoped, AskAiGatewayFactory>(); _ = services.AddScoped(); logger?.LogInformation("Gateway and transformer factories registered successfully - provider switchable via X-AI-Provider header"); + + // Register message feedback components (gateway is singleton for connection reuse) + _ = services.AddSingleton(); + _ = services.AddScoped(); + logger?.LogInformation("AskAiMessageFeedbackUsecase and Elasticsearch gateway registered successfully"); } catch (Exception ex) { @@ -227,7 +232,7 @@ private static void AddSearchUsecase(IServiceCollection services, AppEnv appEnv) { var logger = GetLogger(services); logger?.LogInformation("Configuring Search use case for environment {AppEnvironment}", appEnv); - _ = services.AddScoped(); + _ = services.AddSingleton(); _ = services.AddScoped(); _ = services.AddScoped(); }