+}) => {
+ 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();
}