From 855f70fd38977e14094f1b0ede8b05f60ee5077f Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 02:17:25 +0200 Subject: [PATCH 01/20] Set up api --- .github/workflows/build-api-lambda.yml | 41 +++++ .github/workflows/ci.yml | 7 +- Directory.Packages.props | 4 +- docs-builder.sln | 24 +++ docs/syntax/stepper.md | 2 +- .../SearchOrAskAi/useLlmGateway.ts | 145 +++++++++--------- src/api/AwsLambda/AwsLambda.csproj | 32 ++++ src/api/AwsLambda/Dockerfile | 27 ++++ src/api/AwsLambda/Program.cs | 36 +++++ .../AwsLambda/Properties/launchSettings.json | 23 +++ .../AwsLambda/appsettings.Development.json | 8 + src/api/AwsLambda/appsettings.json | 9 ++ src/api/Core/AskAi/AskAiRequest.cs | 7 + src/api/Core/AskAi/AskAiUsecase.cs | 16 ++ src/api/Core/AskAi/IAskAiGateway.cs | 10 ++ src/api/Core/Core.csproj | 15 ++ src/api/Core/SerializationContext.cs | 13 ++ .../Adapters/GcpIdTokenProvider.cs | 124 +++++++++++++++ .../Adapters/LlmGatewayChatGateway.cs | 63 ++++++++ .../Infrastructure/Aws/IParameterProvider.cs | 10 ++ .../Aws/LambdaExtensionParameterProvider.cs | 64 ++++++++ .../Aws/LocalEnvParameterProvider.cs | 26 ++++ src/api/Infrastructure/Infrastructure.csproj | 26 ++++ src/api/Infrastructure/ServicesExtension.cs | 103 +++++++++++++ .../docs-builder/Http/DocumentationWebHost.cs | 132 +++++----------- src/tooling/docs-builder/docs-builder.csproj | 2 + 26 files changed, 798 insertions(+), 171 deletions(-) create mode 100644 .github/workflows/build-api-lambda.yml create mode 100644 src/api/AwsLambda/AwsLambda.csproj create mode 100644 src/api/AwsLambda/Dockerfile create mode 100644 src/api/AwsLambda/Program.cs create mode 100644 src/api/AwsLambda/Properties/launchSettings.json create mode 100644 src/api/AwsLambda/appsettings.Development.json create mode 100644 src/api/AwsLambda/appsettings.json create mode 100644 src/api/Core/AskAi/AskAiRequest.cs create mode 100644 src/api/Core/AskAi/AskAiUsecase.cs create mode 100644 src/api/Core/AskAi/IAskAiGateway.cs create mode 100644 src/api/Core/Core.csproj create mode 100644 src/api/Core/SerializationContext.cs create mode 100644 src/api/Infrastructure/Adapters/GcpIdTokenProvider.cs create mode 100644 src/api/Infrastructure/Adapters/LlmGatewayChatGateway.cs create mode 100644 src/api/Infrastructure/Aws/IParameterProvider.cs create mode 100644 src/api/Infrastructure/Aws/LambdaExtensionParameterProvider.cs create mode 100644 src/api/Infrastructure/Aws/LocalEnvParameterProvider.cs create mode 100644 src/api/Infrastructure/Infrastructure.csproj create mode 100644 src/api/Infrastructure/ServicesExtension.cs diff --git a/.github/workflows/build-api-lambda.yml b/.github/workflows/build-api-lambda.yml new file mode 100644 index 000000000..d1044c627 --- /dev/null +++ b/.github/workflows/build-api-lambda.yml @@ -0,0 +1,41 @@ +--- +# This workflow is used to build the API lambda +# lambda function bootstrap binary that can be deployed to AWS Lambda. +name: Build API Lambda + +on: + workflow_dispatch: + workflow_call: + inputs: + ref: + required: false + type: string + default: ${{ github.ref }} + +jobs: + build: + runs-on: ubuntu-latest + env: + BINARY_PATH: .artifacts/AwsLambda/release_linux-x64/bootstrap + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + - name: Amazon Linux 2023 build + run: | + docker build . -t api-lambda:latest -f src/api/AwsLambda/DockerFile + - name: Get bootstrap binary + run: | + docker cp $(docker create --name tc api-lambda:latest):/app/.artifacts/publish ./.artifacts && docker rm tc + - name: Inspect bootstrap binary + run: | + tree .artifacts + stat "${BINARY_PATH}" + - name: Archive artifact + id: upload-artifact + uses: actions/upload-artifact@v4 + with: + name: api-lambda-binary + retention-days: 1 + if-no-files-found: error + path: ${{ env.BINARY_PATH }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d3fd09d5..599f464e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,12 @@ jobs: - name: Validate Content Sources run: dotnet run --project src/tooling/docs-assembler -c release -- content-source validate - build-lambda: + build-link-index-updater-lambda: uses: ./.github/workflows/build-link-index-updater-lambda.yml - + + build-api-lambda: + uses: ./.github/workflows/build-api-lambda.yml + npm: runs-on: ubuntu-latest defaults: diff --git a/Directory.Packages.props b/Directory.Packages.props index 4a9060035..e0e2235df 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + @@ -18,6 +19,7 @@ + @@ -70,4 +72,4 @@ - + \ No newline at end of file diff --git a/docs-builder.sln b/docs-builder.sln index 03a3c206d..0e25143c7 100644 --- a/docs-builder.sln +++ b/docs-builder.sln @@ -119,6 +119,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "config", "config", "{6FAB56 config\navigation.yml = config\navigation.yml EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "api", "api", "{B042CC78-5060-4091-B95A-79C71BA3908A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "src\api\Core\Core.csproj", "{F30B90AD-1A01-4A6F-9699-809FA6875B22}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "src\api\Infrastructure\Infrastructure.csproj", "{AE3FC78E-167F-4B6E-88EC-84743EB748B7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda", "src\api\AwsLambda\AwsLambda.csproj", "{C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -204,6 +212,18 @@ Global {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|Any CPU.Build.0 = Debug|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|Any CPU.ActiveCfg = Release|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|Any CPU.Build.0 = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|Any CPU.Build.0 = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|Any CPU.Build.0 = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {4D198E25-C211-41DC-9E84-B15E89BD7048} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} @@ -234,5 +254,9 @@ Global {89B83007-71E6-4B57-BA78-2544BFA476DB} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {111E7029-BB29-4039-9B45-04776798A8DD} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {164F55EC-9412-4CD4-81AD-3598B57632A6} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} + {B042CC78-5060-4091-B95A-79C71BA3908A} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} + {F30B90AD-1A01-4A6F-9699-809FA6875B22} = {B042CC78-5060-4091-B95A-79C71BA3908A} + {AE3FC78E-167F-4B6E-88EC-84743EB748B7} = {B042CC78-5060-4091-B95A-79C71BA3908A} + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE} = {B042CC78-5060-4091-B95A-79C71BA3908A} EndGlobalSection EndGlobal diff --git a/docs/syntax/stepper.md b/docs/syntax/stepper.md index cf52aad6d..f7d156d67 100644 --- a/docs/syntax/stepper.md +++ b/docs/syntax/stepper.md @@ -12,7 +12,7 @@ By default every step title is a link with a generated anchor. You can override :::::{stepper} ::::{step} Install -First install the dependencies. +First install the {{foo}} dependencies. ```shell npm install ``` diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts index 2c9e4c2ff..3bd4143a1 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts @@ -4,25 +4,12 @@ import { EventSourceMessage } from '@microsoft/fetch-event-source' import { useEffect, useState, useRef, useCallback } from 'react' import * as z from 'zod' -export const LlmGatewayRequestSchema = z.object({ - userContext: z.object({ - userEmail: z.string(), - }), - platformContext: z.object({ - origin: z.literal('support_portal'), - useCase: z.literal('support_assistant'), - metadata: z.any(), - }), - input: z.array( - z.object({ - role: z.string(), - message: z.string(), - }) - ), - threadId: z.string(), +export const AskAiRequestSchema = z.object({ + message: z.string(), + threadId: z.string().optional(), }) -export type LlmGatewayRequest = z.infer +export type AskAiRequest = z.infer const sharedAttributes = { timestamp: z.number(), @@ -154,7 +141,7 @@ export const useLlmGateway = (props: Props): UseLlmGatewayResponse => { [processMessage] ) - const { sendMessage, abort } = useFetchEventSource({ + const { sendMessage, abort } = useFetchEventSource({ apiEndpoint: '/chat', onMessage, onError: (error) => { @@ -221,64 +208,70 @@ export const useLlmGateway = (props: Props): UseLlmGatewayResponse => { } } -function createLlmGatewayRequest(question: string, threadId?: string) { - // TODO: we should move this to the backend so that the use cannot change this - // Right now, the backend is a pure proxy to the LLM gateway - return LlmGatewayRequestSchema.parse({ - userContext: { - userEmail: `elastic-docs-v3@invalid`, // Random email (will be optional in the future) - }, - platformContext: { - origin: 'support_portal', - useCase: 'support_assistant', - metadata: {}, - }, - input: [ - { - role: 'user', - message: ` - # ROLE AND GOAL - You are an expert AI assistant for the Elastic Stack (Elasticsearch, Kibana, Beats, Logstash, etc.). Your sole purpose is to answer user questions based *exclusively* on the provided context from the official Elastic Documentation. - - # CRITICAL INSTRUCTION: SINGLE-SHOT INTERACTION - This is a single-turn interaction. The user cannot reply to your answer for clarification. Therefore, your response MUST be final, self-contained, and as comprehensive as possible based on the provided context. - Also, keep the response as short as possible, but do not truncate the context. - - # RULES - 1. **Facts** Always do RAG search to find the relevant Elastic documentation. - 2. **Strictly Grounded Answers:** You MUST base your answer 100% on the information from the search results. Do not use any of your pre-trained knowledge or any information outside of this context. - 3. **Handle Ambiguity Gracefully:** Since you cannot ask clarifying questions, if the question is broad or ambiguous (e.g., "how to improve performance"), structure your answer to cover the different interpretations supported by the context. - * Acknowledge the ambiguity. For example: "Your question about 'performance' can cover several areas. Based on the documentation, here are the key aspects:" - * Organize the answer with clear headings for each aspect (e.g., "Indexing Performance," "Query Performance"). - * But if there is a similar or related topic in the docs you can mention it and link to it. - 4. **Direct Answer First:** If the context directly and sufficiently answers a specific question, provide a clear, comprehensive, and well-structured answer. - * Use Markdown for formatting (e.g., code blocks for configurations, bullet points for lists). - * Use LaTeX for mathematical or scientific notations where appropriate (e.g., \`$E = mc^2$\`). - * Make the answer as complete as possible, as this is the user's only response. - * Keep the answer short and concise. We want to link users to the Elastic Documentation to find more information. - 5. **Handling Incomplete Answers:** If the context contains relevant information but does not fully answer the question, you MUST follow this procedure: - * Start by explicitly stating that you could not find a complete answer. - * Then, summarize the related information you *did* find in the context, explaining how it might be helpful. - 6. **Handling No Answer:** If the context is empty or completely irrelevant to the question, you MUST respond with the following, and nothing else: - I was unable to find an answer to your question in the Elastic Documentation. - - For further assistance, you may want to: - * Ask the community of experts at **discuss.elastic.co**. - * If you have an Elastic subscription, contact our support engineers at **support.elastic.co**." - 7. If you are 100% sure that something is not supported by Elastic, then say so. - 8. **Tone:** Your tone should be helpful, professional, and confident. It is better to provide no answer (Rule #5) than an incorrect one. - * Assume that the user is using Elastic for the first time. - * Assume that the user is a beginner. - * Assume that the user has a limited knowledge of Elastic - * Explain unusual terminology, abbreviations, or acronyms. - * Always try to cite relevant Elastic documentation. - `, - }, - { - role: 'user', - message: question, - }, - ], +function createLlmGatewayRequest( + message: string, + threadId?: string +): AskAiRequest { + return AskAiRequestSchema.parse({ + message, threadId, }) + + // return LlmGatewayRequestSchema.parse({ + // userContext: { + // userEmail: `elastic-docs-v3@invalid`, // Random email (will be optional in the future) + // }, + // platformContext: { + // origin: 'support_portal', + // useCase: 'support_assistant', + // metadata: {}, + // }, + // input: [ + // { + // role: 'user', + // message: ` + // # ROLE AND GOAL + // You are an expert AI assistant for the Elastic Stack (Elasticsearch, Kibana, Beats, Logstash, etc.). Your sole purpose is to answer user questions based *exclusively* on the provided context from the official Elastic Documentation. + // + // # CRITICAL INSTRUCTION: SINGLE-SHOT INTERACTION + // This is a single-turn interaction. The user cannot reply to your answer for clarification. Therefore, your response MUST be final, self-contained, and as comprehensive as possible based on the provided context. + // Also, keep the response as short as possible, but do not truncate the context. + // + // # RULES + // 1. **Facts** Always do RAG search to find the relevant Elastic documentation. + // 2. **Strictly Grounded Answers:** You MUST base your answer 100% on the information from the search results. Do not use any of your pre-trained knowledge or any information outside of this context. + // 3. **Handle Ambiguity Gracefully:** Since you cannot ask clarifying questions, if the question is broad or ambiguous (e.g., "how to improve performance"), structure your answer to cover the different interpretations supported by the context. + // * Acknowledge the ambiguity. For example: "Your question about 'performance' can cover several areas. Based on the documentation, here are the key aspects:" + // * Organize the answer with clear headings for each aspect (e.g., "Indexing Performance," "Query Performance"). + // * But if there is a similar or related topic in the docs you can mention it and link to it. + // 4. **Direct Answer First:** If the context directly and sufficiently answers a specific question, provide a clear, comprehensive, and well-structured answer. + // * Use Markdown for formatting (e.g., code blocks for configurations, bullet points for lists). + // * Use LaTeX for mathematical or scientific notations where appropriate (e.g., \`$E = mc^2$\`). + // * Make the answer as complete as possible, as this is the user's only response. + // * Keep the answer short and concise. We want to link users to the Elastic Documentation to find more information. + // 5. **Handling Incomplete Answers:** If the context contains relevant information but does not fully answer the question, you MUST follow this procedure: + // * Start by explicitly stating that you could not find a complete answer. + // * Then, summarize the related information you *did* find in the context, explaining how it might be helpful. + // 6. **Handling No Answer:** If the context is empty or completely irrelevant to the question, you MUST respond with the following, and nothing else: + // I was unable to find an answer to your question in the Elastic Documentation. + // + // For further assistance, you may want to: + // * Ask the community of experts at **discuss.elastic.co**. + // * If you have an Elastic subscription, contact our support engineers at **support.elastic.co**." + // 7. If you are 100% sure that something is not supported by Elastic, then say so. + // 8. **Tone:** Your tone should be helpful, professional, and confident. It is better to provide no answer (Rule #5) than an incorrect one. + // * Assume that the user is using Elastic for the first time. + // * Assume that the user is a beginner. + // * Assume that the user has a limited knowledge of Elastic + // * Explain unusual terminology, abbreviations, or acronyms. + // * Always try to cite relevant Elastic documentation. + // `, + // }, + // { + // role: 'user', + // message: question, + // }, + // ], + // threadId, + // }) } diff --git a/src/api/AwsLambda/AwsLambda.csproj b/src/api/AwsLambda/AwsLambda.csproj new file mode 100644 index 000000000..2bd3d0f9e --- /dev/null +++ b/src/api/AwsLambda/AwsLambda.csproj @@ -0,0 +1,32 @@ + + + + Exe + net9.0 + enable + enable + true + + bootstrap + Lambda + + true + true + true + true + false + Linux + + Elastic.Documentation.Lambda.Api + + + + + + + + + + + + diff --git a/src/api/AwsLambda/Dockerfile b/src/api/AwsLambda/Dockerfile new file mode 100644 index 000000000..654c52683 --- /dev/null +++ b/src/api/AwsLambda/Dockerfile @@ -0,0 +1,27 @@ +FROM public.ecr.aws/amazonlinux/amazonlinux:2023 AS base + +ARG TARGETARCH +ARG TARGETOS + +WORKDIR /app + +RUN rpm --import https://packages.microsoft.com/keys/microsoft.asc + +RUN curl -o /etc/yum.repos.d/microsoft-prod.repo https://packages.microsoft.com/config/fedora/39/prod.repo + +RUN dnf update -y +RUN dnf install -y dotnet-sdk-9.0 +RUN dnf install -y npm +RUN dnf install -y git +RUN dnf install -y clang + +COPY . . + +ENV DOTNET_NOLOGO=true \ + DOTNET_CLI_TELEMETRY_OPTOUT=true + +RUN arch=$TARGETARCH \ + && if [ "$arch" = "amd64" ]; then arch="x64"; fi \ + && echo $TARGETOS-$arch > /tmp/rid + +RUN dotnet publish src/api/AwsLambda -r linux-x64 -c Release diff --git a/src/api/AwsLambda/Program.cs b/src/api/AwsLambda/Program.cs new file mode 100644 index 000000000..134d76aea --- /dev/null +++ b/src/api/AwsLambda/Program.cs @@ -0,0 +1,36 @@ +// 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; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Serialization.SystemTextJson; +using Api.Core; +using Api.Core.AskAi; +using Api.Infrastructure; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default); +}); + +builder.Services.AddHttpClient(); +builder.Services.AddUsecases(Environment.GetEnvironmentVariable("APP_ENVIRONMENT")); + +builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi, new SourceGeneratorLambdaJsonSerializer()); + +var app = builder.Build(); + +app.MapPost("/ask-ai/stream", async (AskAiRequest askAiRequest, AskAiUsecase askAiUsecase, Cancel ctx) => +{ + var stream = await askAiUsecase.AskAi(askAiRequest, ctx); + return Results.Stream(stream, "text/event-stream"); +}); + +app.Run(); + +[JsonSerializable(typeof(APIGatewayHttpApiV2ProxyRequest), GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(APIGatewayHttpApiV2ProxyResponse), GenerationMode = JsonSourceGenerationMode.Default)] +internal sealed partial class LambdaJsonSerializerContext : JsonSerializerContext { } diff --git a/src/api/AwsLambda/Properties/launchSettings.json b/src/api/AwsLambda/Properties/launchSettings.json new file mode 100644 index 000000000..b3c490b26 --- /dev/null +++ b/src/api/AwsLambda/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5020", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7192;http://localhost:5020", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/api/AwsLambda/appsettings.Development.json b/src/api/AwsLambda/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/api/AwsLambda/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/api/AwsLambda/appsettings.json b/src/api/AwsLambda/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/src/api/AwsLambda/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/api/Core/AskAi/AskAiRequest.cs b/src/api/Core/AskAi/AskAiRequest.cs new file mode 100644 index 000000000..2693fa68f --- /dev/null +++ b/src/api/Core/AskAi/AskAiRequest.cs @@ -0,0 +1,7 @@ +// 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 Api.Core.AskAi; + +public record AskAiRequest(string Message, string? ThreadId); diff --git a/src/api/Core/AskAi/AskAiUsecase.cs b/src/api/Core/AskAi/AskAiUsecase.cs new file mode 100644 index 000000000..0840321e3 --- /dev/null +++ b/src/api/Core/AskAi/AskAiUsecase.cs @@ -0,0 +1,16 @@ +// 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 Microsoft.Extensions.Logging; + +namespace Api.Core.AskAi; + +public class AskAiUsecase(IAskAiGateway askAiGateway, ILogger logger) +{ + public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx) + { + logger.LogDebug("Processing AskAiRequest: {Request}", askAiRequest); + return await askAiGateway.AskAi(askAiRequest, ctx); + } +} diff --git a/src/api/Core/AskAi/IAskAiGateway.cs b/src/api/Core/AskAi/IAskAiGateway.cs new file mode 100644 index 000000000..8d5d68271 --- /dev/null +++ b/src/api/Core/AskAi/IAskAiGateway.cs @@ -0,0 +1,10 @@ +// 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 Api.Core.AskAi; + +public interface IAskAiGateway +{ + Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default); +} diff --git a/src/api/Core/Core.csproj b/src/api/Core/Core.csproj new file mode 100644 index 000000000..3e6ddc67e --- /dev/null +++ b/src/api/Core/Core.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + Api.Core + Api.Core + + + + + + + diff --git a/src/api/Core/SerializationContext.cs b/src/api/Core/SerializationContext.cs new file mode 100644 index 000000000..5504f7155 --- /dev/null +++ b/src/api/Core/SerializationContext.cs @@ -0,0 +1,13 @@ +// 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; +using Api.Core.AskAi; + +namespace Api.Core; + + +[JsonSerializable(typeof(AskAiRequest))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +public partial class ApiJsonContext : JsonSerializerContext; diff --git a/src/api/Infrastructure/Adapters/GcpIdTokenProvider.cs b/src/api/Infrastructure/Adapters/GcpIdTokenProvider.cs new file mode 100644 index 000000000..e6c32297b --- /dev/null +++ b/src/api/Infrastructure/Adapters/GcpIdTokenProvider.cs @@ -0,0 +1,124 @@ +// 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.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Api.Infrastructure.Adapters; + +// This is a custom implementation to create an ID token for GCP. +// Because Google.Api.Auth.OAuth2 is not compatible with AOT +public class GcpIdTokenProvider(HttpClient httpClient, string gcpServiceAccount, string targetAudience) +{ + public async Task GenerateIdTokenAsync(Cancel cancellationToken = default) + { + // Read and parse service account key file using System.Text.Json source generation (AOT compatible) + var serviceAccount = JsonSerializer.Deserialize(gcpServiceAccount, GcpJsonContext.Default.ServiceAccountKey); + + // Create JWT header + var header = new JwtHeader("RS256", "JWT", serviceAccount.PrivateKeyId); + var headerJson = JsonSerializer.Serialize(header, JwtHeaderJsonContext.Default.JwtHeader); + var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); + + // Create JWT payload + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var payload = new JwtPayload( + serviceAccount.ClientEmail, + serviceAccount.ClientEmail, + "https://oauth2.googleapis.com/token", + now, + now + 300, // 5 minutes + targetAudience + ); + + var payloadJson = JsonSerializer.Serialize(payload, GcpJsonContext.Default.JwtPayload); + var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); + + // Create signature + var message = $"{headerBase64}.{payloadBase64}"; + var messageBytes = Encoding.UTF8.GetBytes(message); + + // Parse the private key (removing PEM headers/footers and decoding) + var privateKeyPem = serviceAccount.PrivateKey + .Replace("-----BEGIN PRIVATE KEY-----", "") + .Replace("-----END PRIVATE KEY-----", "") + .Replace("\n", "") + .Replace("\r", ""); + var privateKeyBytes = Convert.FromBase64String(privateKeyPem); + + // Create RSA instance and sign + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(privateKeyBytes, out _); + var signature = rsa.SignData(messageBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var signatureBase64 = Base64UrlEncode(signature); + + var jwt = $"{message}.{signatureBase64}"; + + // Exchange JWT for ID token + return await ExchangeJwtForIdToken(jwt, targetAudience, cancellationToken); + } + + + private async Task ExchangeJwtForIdToken(string jwt, string targetAudience, Cancel cancellationToken) + { + var requestContent = new FormUrlEncodedContent([ + new KeyValuePair("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + new KeyValuePair("assertion", jwt), + new KeyValuePair("target_audience", targetAudience) + ]); + + var response = await httpClient.PostAsync("https://oauth2.googleapis.com/token", requestContent, cancellationToken); + _ = response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + using var document = JsonDocument.Parse(responseJson); + + if (document.RootElement.TryGetProperty("id_token", out var idTokenElement)) + return idTokenElement.GetString() ?? throw new InvalidOperationException("ID token is null"); + + throw new InvalidOperationException("No id_token found in response"); + } + + private static string Base64UrlEncode(byte[] input) + { + var base64 = Convert.ToBase64String(input); + // Convert base64 to base64url encoding + return base64.Replace('+', '-').Replace('/', '_').TrimEnd('='); + } +} + +internal readonly record struct ServiceAccountKey( + string Type, + string ProjectId, + string PrivateKeyId, + string PrivateKey, + string ClientEmail, + string ClientId, + string AuthUri, + string TokenUri, + string AuthProviderX509CertUrl, + string ClientX509CertUrl +); + +internal readonly record struct JwtHeader(string Alg, string Typ, string Kid); + +internal readonly record struct JwtPayload( + string Iss, + string Sub, + string Aud, + long Iat, + long Exp, + string TargetAudience +); + +[JsonSerializable(typeof(ServiceAccountKey))] +[JsonSerializable(typeof(JwtPayload))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +internal sealed partial class GcpJsonContext : JsonSerializerContext; + +[JsonSerializable(typeof(JwtHeader))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class JwtHeaderJsonContext : JsonSerializerContext; diff --git a/src/api/Infrastructure/Adapters/LlmGatewayChatGateway.cs b/src/api/Infrastructure/Adapters/LlmGatewayChatGateway.cs new file mode 100644 index 000000000..413f3b2de --- /dev/null +++ b/src/api/Infrastructure/Adapters/LlmGatewayChatGateway.cs @@ -0,0 +1,63 @@ +// 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; +using System.Text.Json; +using System.Text.Json.Serialization; +using Api.Core.AskAi; + +namespace Api.Infrastructure.Adapters; + +public class LlmGatewayAskAiGateway(HttpClient httpClient, GcpIdTokenProvider tokenProvider, string gcpFunctionUrl) : IAskAiGateway +{ + public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) + { + var llmGatewayRequest = LlmGatewayRequest.CreateFromQuestion(askAiRequest.Message, askAiRequest.ThreadId); + var requestBody = JsonSerializer.Serialize(llmGatewayRequest, LlmGatewayContext.Default.LlmGatewayRequest); + var request = new HttpRequestMessage(HttpMethod.Post, gcpFunctionUrl) + { + Content = new StringContent(requestBody, Encoding.UTF8, "application/json") + }; + var authToken = await tokenProvider.GenerateIdTokenAsync(ctx); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", authToken); + request.Headers.Add("User-Agent", "elastic-docs-proxy/1.0"); + request.Headers.Add("Accept", "text/event-stream"); + request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); + return await response.Content.ReadAsStreamAsync(ctx); + } +} + +public record LlmGatewayRequest( + UserContext UserContext, + PlatformContext PlatformContext, + ChatInput[] Input, + string ThreadId +) +{ + public static LlmGatewayRequest CreateFromQuestion(string question, string? threadId = null) => + new( + UserContext: new UserContext("elastic-docs-v3@invalid"), + PlatformContext: new PlatformContext("support_portal", "support_assistant", []), + Input: + [ + new ChatInput("user", question) + ], + ThreadId: threadId ?? "elastic-docs-" + Guid.NewGuid() + ); +} + +public record UserContext(string UserEmail); + +public record PlatformContext( + string Origin, + string UseCase, + Dictionary? Metadata = null +); + +public record ChatInput(string Role, string Message); + +[JsonSerializable(typeof(LlmGatewayRequest))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class LlmGatewayContext : JsonSerializerContext; diff --git a/src/api/Infrastructure/Aws/IParameterProvider.cs b/src/api/Infrastructure/Aws/IParameterProvider.cs new file mode 100644 index 000000000..33e4b64e2 --- /dev/null +++ b/src/api/Infrastructure/Aws/IParameterProvider.cs @@ -0,0 +1,10 @@ +// 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 Api.Infrastructure.Aws; + +public interface IParameterProvider +{ + Task GetParam(string name, bool withDecryption = true, Cancel ctx = default); +} diff --git a/src/api/Infrastructure/Aws/LambdaExtensionParameterProvider.cs b/src/api/Infrastructure/Aws/LambdaExtensionParameterProvider.cs new file mode 100644 index 000000000..5bf3ab1e0 --- /dev/null +++ b/src/api/Infrastructure/Aws/LambdaExtensionParameterProvider.cs @@ -0,0 +1,64 @@ +// 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.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Api.Infrastructure.Aws; + +public class LambdaExtensionParameterProvider(IHttpClientFactory httpClientFactory, ILogger logger) : IParameterProvider +{ + public const string HttpClientName = "AwsParametersAndSecretsLambdaExtensionClient"; + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + + public async Task GetParam(string name, bool withDecryption = true, Cancel ctx = default) + { + try + { + logger.LogInformation("Retrieving parameter '{Name}' from Lambda Extension (SSM Parameter Store).", name); + var response = await _httpClient.GetFromJsonAsync($"/systemsmanager/parameters/get?name={Uri.EscapeDataString(name)}&withDecryption={withDecryption.ToString().ToLowerInvariant()}", AwsJsonContext.Default.ParameterResponse, ctx); + return response?.Parameter?.Value ?? throw new InvalidOperationException($"Parameter value for '{name}' is null."); + } + catch (HttpRequestException httpEx) + { + logger.LogError(httpEx, "HTTP request failed for parameter '{Name}'. Status: {StatusCode}.", name, httpEx.StatusCode); + throw; + } + catch (JsonException jsonEx) + { + logger.LogError(jsonEx, "JSON deserialization failed for parameter '{Name}'.", name); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "An unexpected error occurred while retrieving parameter '{Name}'.", name); + throw; + } + } +} + +internal sealed class ParameterResponse +{ + public Parameter? Parameter { get; set; } +} + +internal sealed class Parameter +{ + public string? Arn { get; set; } + public string? Name { get; set; } + public string? Type { get; set; } + public string? Value { get; set; } + public string? Version { get; set; } + public string? Selector { get; set; } + public string? LastModifiedDate { get; set; } + public string? LastModifiedUser { get; set; } + public string? DataType { get; set; } +} + + +[JsonSerializable(typeof(ParameterResponse))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class AwsJsonContext : JsonSerializerContext; diff --git a/src/api/Infrastructure/Aws/LocalEnvParameterProvider.cs b/src/api/Infrastructure/Aws/LocalEnvParameterProvider.cs new file mode 100644 index 000000000..4d6e538f2 --- /dev/null +++ b/src/api/Infrastructure/Aws/LocalEnvParameterProvider.cs @@ -0,0 +1,26 @@ +// 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 Microsoft.Extensions.Configuration; + +namespace Api.Infrastructure.Aws; + +public class LocalEnvParameterProvider : IParameterProvider +{ + public Task GetParam(string name, bool withDecryption = true, Cancel ctx = default) + { + var env = name switch + { + "/elastic-docs-v3/dev/llm-gateway-service-account" => "LLM_GATEWAY_SERVICE_ACCOUNT", + "/elastic-docs-v3/dev/llm-gateway-function-url" => "LLM_GATEWAY_FUNCTION_URL", + _ => throw new ArgumentOutOfRangeException(nameof(name), name, null) + }; + var value = Environment.GetEnvironmentVariable(env); + + if (string.IsNullOrEmpty(value)) + throw new ArgumentException($"Environment variable '{env}' not found."); + + return Task.FromResult(value); + } +} diff --git a/src/api/Infrastructure/Infrastructure.csproj b/src/api/Infrastructure/Infrastructure.csproj new file mode 100644 index 000000000..758c6b223 --- /dev/null +++ b/src/api/Infrastructure/Infrastructure.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + Api.Infrastructure + Api.Infrastructure + + + + + + + + + + + + + + ..\..\..\..\..\..\..\usr\local\share\dotnet\shared\Microsoft.AspNetCore.App\9.0.5\Microsoft.Extensions.Configuration.Abstractions.dll + + + + diff --git a/src/api/Infrastructure/ServicesExtension.cs b/src/api/Infrastructure/ServicesExtension.cs new file mode 100644 index 000000000..c5304ac6f --- /dev/null +++ b/src/api/Infrastructure/ServicesExtension.cs @@ -0,0 +1,103 @@ +// 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.ComponentModel.DataAnnotations; +using Api.Core.AskAi; +using Api.Infrastructure.Adapters; +using Api.Infrastructure.Aws; +using Microsoft.Extensions.DependencyInjection; +using NetEscapades.EnumGenerators; + +namespace Api.Infrastructure; + +[EnumExtensions] +public enum AppEnvironment +{ + [Display(Name = "dev")] Dev, + [Display(Name = "staging")] Staging, + [Display(Name = "edge")] Edge, + [Display(Name = "prod")] Prod +} + +public static class ServicesExtension +{ + public static void AddUsecases(this IServiceCollection services, string? appEnvironment) => + AddUsecases( + services, + AppEnvironmentExtensions.TryParse(appEnvironment, out var parsedEnvironment, true) + ? parsedEnvironment + : AppEnvironment.Dev + ); + + private static void AddUsecases(this IServiceCollection services, AppEnvironment appEnvironment) + { + AddParameterProvider(services, appEnvironment); + AddAskAiUsecases(services, appEnvironment); + } + + // https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html + private static void AddParameterProvider(IServiceCollection services, AppEnvironment appEnvironment) + { + switch (appEnvironment) + { + case AppEnvironment.Prod: + case AppEnvironment.Staging: + case AppEnvironment.Edge: + { + _ = services.AddHttpClient(LambdaExtensionParameterProvider.HttpClientName, client => + { + client.BaseAddress = new Uri("http://localhost:2773"); + client.DefaultRequestHeaders.Add("X-Aws-Parameters-Secrets-Token", Environment.GetEnvironmentVariable("AWS_SESSION_TOKEN")); + }); + _ = services.AddSingleton(); + break; + } + case AppEnvironment.Dev: + { + _ = services.AddSingleton(); + break; + } + default: + { + throw new ArgumentOutOfRangeException(nameof(appEnvironment), appEnvironment, + "Unsupported environment for parameter provider."); + } + } + } + + private static void AddAskAiUsecases(IServiceCollection services, AppEnvironment appEnvironment) + { + _ = services.AddScoped(serviceProvider => + { + var httpClient = serviceProvider.GetRequiredService(); + var parameterProvider = serviceProvider.GetRequiredService(); + var appEnvString = appEnvironment.ToStringFast(true); + + var serviceAccount = parameterProvider + .GetParam($"/elastic-docs-v3/{appEnvString}/llm-gateway-service-account") + .GetAwaiter() + .GetResult(); + var functionUrl = parameterProvider + .GetParam($"/elastic-docs-v3/{appEnvString}/llm-gateway-function-url") + .GetAwaiter() + .GetResult(); + + var functionUri = new Uri(functionUrl); + var targetAudience = $"{functionUri.Scheme}://{functionUri.Host}"; + + return new GcpIdTokenProvider(httpClient, serviceAccount, targetAudience); + }); + + _ = services.AddScoped>(serviceProvider => + { + var parameterProvider = serviceProvider.GetRequiredService(); + var tokenProvider = serviceProvider.GetRequiredService(); + var httpClient = serviceProvider.GetRequiredService(); + var functionUrl = parameterProvider.GetParam("/elastic-docs-v3/dev/llm-gateway-function-url").GetAwaiter().GetResult(); + return new LlmGatewayAskAiGateway(httpClient, tokenProvider, functionUrl); + }); + + _ = services.AddScoped(); + } +} diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 8469d1431..36a91a592 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -6,6 +6,10 @@ using System.Net; using System.Runtime.InteropServices; using System.Text; +using System.Text.Json; +using Api.Core; +using Api.Core.AskAi; +using Api.Infrastructure.Adapters; using Documentation.Builder.Diagnostics.LiveMode; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Versions; @@ -222,103 +226,49 @@ private static IResult LiveReloadHtml(string content, Encoding? encoding = null, private static async Task ProxyChatRequest(HttpContext context, CancellationToken ctx) { - try - { - // Read the frontend request body - var requestBody = await new StreamReader(context.Request.Body).ReadToEndAsync(ctx); - - // Load GCP service account credentials - var serviceAccountKeyPath = Environment.GetEnvironmentVariable("GCP_SERVICE_ACCOUNT_KEY_PATH") - ?? "service-account-key.json"; - - if (!File.Exists(serviceAccountKeyPath)) - { - context.Response.StatusCode = 500; - await context.Response.WriteAsync("GCP credentials not configured", cancellationToken: ctx); - return Results.Empty; - } + // Read the frontend request body + var requestBody = await new StreamReader(context.Request.Body).ReadToEndAsync(ctx); + var askAiRequest = JsonSerializer.Deserialize(requestBody, ApiJsonContext.Default.AskAiRequest); - // Get GCP function URL - var gcpFunctionUrl = Environment.GetEnvironmentVariable("GCP_CHAT_FUNCTION_URL"); - if (string.IsNullOrEmpty(gcpFunctionUrl)) - { - context.Response.StatusCode = 500; - await context.Response.WriteAsync("GCP function URL not configured", cancellationToken: ctx); - return Results.Empty; - } - - // Extract base URL for ID token audience (service URL without path) - var functionUri = new Uri(gcpFunctionUrl); - var audienceUrl = $"{functionUri.Scheme}://{functionUri.Host}"; - - // Generate ID token using AOT-compatible approach - var idToken = await GcpIdTokenGenerator.GenerateIdTokenAsync(serviceAccountKeyPath, audienceUrl, ctx); - - // Make request to GCP function - using var httpClient = new HttpClient(); - var request = new HttpRequestMessage(HttpMethod.Post, gcpFunctionUrl) - { - Content = new StringContent(requestBody, Encoding.UTF8, "application/json") - }; + // Load GCP service account credentials + var serviceAccountKeyPath = Environment.GetEnvironmentVariable("LLM_GATEWAY_GCP_SERVICE_ACCOUNT_KEY_PATH") + ?? "service-account-key.json"; - // Add authorization header with ID token - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", idToken); - - // Add additional headers that GCP functions commonly require - request.Headers.Add("User-Agent", "docs-builder-proxy/1.0"); - request.Headers.Add("Accept", "text/event-stream, application/json"); - - // Ensure Content-Type is set properly for the request body - request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - - var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(ctx); - Console.WriteLine($"[CHAT PROXY] Error response: {errorContent}"); - context.Response.StatusCode = (int)response.StatusCode; - await context.Response.WriteAsync(errorContent, cancellationToken: ctx); - return Results.Empty; - } - - // Forward the response - context.Response.StatusCode = (int)response.StatusCode; - context.Response.ContentType = response.Content.Headers.ContentType?.ToString(); - - // // Copy response headers (but skip headers that shouldn't be forwarded) - // foreach (var header in response.Headers) - // { - // // Skip headers that can cause issues in proxy scenarios - // if (header.Key.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) - // || header.Key.Equals("Connection", StringComparison.OrdinalIgnoreCase) - // || header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) - // { - // continue; - // } - // context.Response.Headers[header.Key] = header.Value.ToArray(); - // } - // foreach (var header in response.Content.Headers) - // { - // // Skip Content-Length as it may conflict with chunked streaming - // if (header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) - // { - // continue; - // } - // context.Response.Headers[header.Key] = header.Value.ToArray(); - // } - - // Stream the response - await using var responseStream = await response.Content.ReadAsStreamAsync(ctx); - await responseStream.CopyToAsync(context.Response.Body, ctx); + if (!File.Exists(serviceAccountKeyPath)) + { + context.Response.StatusCode = 500; + await context.Response.WriteAsync("GCP credentials not configured", cancellationToken: ctx); + return Results.Empty; } - catch (Exception ex) + + // Get GCP function URL + var gcpFunctionUrl = Environment.GetEnvironmentVariable("LLM_GATEWAY_GCP_FUNCTION_URL"); + if (string.IsNullOrEmpty(gcpFunctionUrl)) { - Console.WriteLine($"[CHAT PROXY] Exception: {ex.Message}"); context.Response.StatusCode = 500; - await context.Response.WriteAsync($"Error proxying request: {ex.Message}", cancellationToken: ctx); + await context.Response.WriteAsync("GCP function URL not configured", cancellationToken: ctx); + return Results.Empty; } - return Results.Empty; + var functionUri = new Uri(gcpFunctionUrl); + var audienceUrl = $"{functionUri.Scheme}://{functionUri.Host}"; + var httpClient = new HttpClient(); + var gcpIdTokenProvider = new GcpIdTokenProvider( + httpClient, + await File.ReadAllTextAsync(serviceAccountKeyPath, ctx), + audienceUrl + ); + var llmGatewayAskAiGateway = new LlmGatewayAskAiGateway( + httpClient, + gcpIdTokenProvider, + gcpFunctionUrl + ); + var askAiUsecase = new AskAiUsecase(llmGatewayAskAiGateway); + + if (askAiRequest == null) + return Results.BadRequest("Invalid chat request."); + + var responseStream = await askAiUsecase.AskAi(askAiRequest, ctx); + return Results.Stream(responseStream, "text/event-stream"); } } diff --git a/src/tooling/docs-builder/docs-builder.csproj b/src/tooling/docs-builder/docs-builder.csproj index 9eb1616ce..937ef70b5 100644 --- a/src/tooling/docs-builder/docs-builder.csproj +++ b/src/tooling/docs-builder/docs-builder.csproj @@ -26,6 +26,8 @@ + + From 26107419f2211c975ec6af03e878077cf132c1e4 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 02:19:36 +0200 Subject: [PATCH 02/20] Fix Dockerfile path --- .github/workflows/build-api-lambda.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-api-lambda.yml b/.github/workflows/build-api-lambda.yml index d1044c627..d16afac0d 100644 --- a/.github/workflows/build-api-lambda.yml +++ b/.github/workflows/build-api-lambda.yml @@ -23,7 +23,7 @@ jobs: ref: ${{ inputs.ref }} - name: Amazon Linux 2023 build run: | - docker build . -t api-lambda:latest -f src/api/AwsLambda/DockerFile + docker build . -t api-lambda:latest -f src/api/AwsLambda/Dockerfile - name: Get bootstrap binary run: | docker cp $(docker create --name tc api-lambda:latest):/app/.artifacts/publish ./.artifacts && docker rm tc From c9d67b0932407ddc774d1d83079c8d6822728573 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 12:47:00 +0200 Subject: [PATCH 03/20] Refactor naming --- .editorconfig | 4 ++ .github/workflows/build-api-lambda.yml | 4 +- docs-builder.sln | 6 +-- .../AskAi/AskAiRequest.cs | 3 +- .../AskAi/AskAiUsecase.cs | 4 +- .../AskAi/IAskAiGateway.cs | 2 +- .../Elastic.Documentation.Api.Core.csproj} | 4 +- .../SerializationContext.cs | 4 +- .../Adapters/GcpIdTokenProvider.cs | 2 +- .../Adapters/LlmGatewayChatGateway.cs | 4 +- .../Aws/IParameterProvider.cs | 2 +- .../Aws/LambdaExtensionParameterProvider.cs | 2 +- .../Aws/LocalParameterProvider.cs | 38 +++++++++++++++++++ ...c.Documentation.Api.Infrastructure.csproj} | 6 +-- .../ServicesExtension.cs | 10 ++--- .../AskAiEndpoint.cs | 20 ++++++++++ .../Dockerfile | 0 .../Elastic.Documentation.Api.Lambda.csproj} | 9 +++-- .../Program.cs | 16 +++----- .../Properties/launchSettings.json | 0 .../appsettings.Development.json | 0 .../appsettings.json | 0 .../Aws/LocalEnvParameterProvider.cs | 26 ------------- .../docs-builder/Http/DocumentationWebHost.cs | 17 +++++---- src/tooling/docs-builder/docs-builder.csproj | 4 +- 25 files changed, 112 insertions(+), 75 deletions(-) rename src/api/{Core => Elastic.Documentation.Api.Core}/AskAi/AskAiRequest.cs (70%) rename src/api/{Core => Elastic.Documentation.Api.Core}/AskAi/AskAiUsecase.cs (83%) rename src/api/{Core => Elastic.Documentation.Api.Core}/AskAi/IAskAiGateway.cs (86%) rename src/api/{Core/Core.csproj => Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj} (70%) rename src/api/{Core => Elastic.Documentation.Api.Core}/SerializationContext.cs (83%) rename src/api/{Infrastructure => Elastic.Documentation.Api.Infrastructure}/Adapters/GcpIdTokenProvider.cs (98%) rename src/api/{Infrastructure => Elastic.Documentation.Api.Infrastructure}/Adapters/LlmGatewayChatGateway.cs (95%) rename src/api/{Infrastructure => Elastic.Documentation.Api.Infrastructure}/Aws/IParameterProvider.cs (85%) rename src/api/{Infrastructure => Elastic.Documentation.Api.Infrastructure}/Aws/LambdaExtensionParameterProvider.cs (97%) create mode 100644 src/api/Elastic.Documentation.Api.Infrastructure/Aws/LocalParameterProvider.cs rename src/api/{Infrastructure/Infrastructure.csproj => Elastic.Documentation.Api.Infrastructure/Elastic.Documentation.Api.Infrastructure.csproj} (73%) rename src/api/{Infrastructure => Elastic.Documentation.Api.Infrastructure}/ServicesExtension.cs (92%) create mode 100644 src/api/Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs rename src/api/{AwsLambda => Elastic.Documentation.Api.Lambda}/Dockerfile (100%) rename src/api/{AwsLambda/AwsLambda.csproj => Elastic.Documentation.Api.Lambda/Elastic.Documentation.Api.Lambda.csproj} (65%) rename src/api/{AwsLambda => Elastic.Documentation.Api.Lambda}/Program.cs (74%) rename src/api/{AwsLambda => Elastic.Documentation.Api.Lambda}/Properties/launchSettings.json (100%) rename src/api/{AwsLambda => Elastic.Documentation.Api.Lambda}/appsettings.Development.json (100%) rename src/api/{AwsLambda => Elastic.Documentation.Api.Lambda}/appsettings.json (100%) delete mode 100644 src/api/Infrastructure/Aws/LocalEnvParameterProvider.cs diff --git a/.editorconfig b/.editorconfig index d6c74b153..56bbd9043 100644 --- a/.editorconfig +++ b/.editorconfig @@ -247,6 +247,10 @@ dotnet_diagnostic.IDE0305.severity = none dotnet_diagnostic.IDE0072.severity = none +[src/api/AwsLambda/**/*.cs] +dotnet_diagnostic.IL3050.severity = none +dotnet_diagnostic.IL2026.severity = none + [DocumentationWebHost.cs] dotnet_diagnostic.IL3050.severity = none diff --git a/.github/workflows/build-api-lambda.yml b/.github/workflows/build-api-lambda.yml index d16afac0d..acd39b6cc 100644 --- a/.github/workflows/build-api-lambda.yml +++ b/.github/workflows/build-api-lambda.yml @@ -16,14 +16,14 @@ jobs: build: runs-on: ubuntu-latest env: - BINARY_PATH: .artifacts/AwsLambda/release_linux-x64/bootstrap + BINARY_PATH: .artifacts/Elastic.Documentation.Api.Lambda/release_linux-x64/bootstrap steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.ref }} - name: Amazon Linux 2023 build run: | - docker build . -t api-lambda:latest -f src/api/AwsLambda/Dockerfile + docker build . -t api-lambda:latest -f src/api/Elastic.Documentation.Api.Lambda/Dockerfile - name: Get bootstrap binary run: | docker cp $(docker create --name tc api-lambda:latest):/app/.artifacts/publish ./.artifacts && docker rm tc diff --git a/docs-builder.sln b/docs-builder.sln index 0e25143c7..bc53345b7 100644 --- a/docs-builder.sln +++ b/docs-builder.sln @@ -121,11 +121,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "config", "config", "{6FAB56 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "api", "api", "{B042CC78-5060-4091-B95A-79C71BA3908A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "src\api\Core\Core.csproj", "{F30B90AD-1A01-4A6F-9699-809FA6875B22}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Api.Core", "src\api\Elastic.Documentation.Api.Core\Elastic.Documentation.Api.Core.csproj", "{F30B90AD-1A01-4A6F-9699-809FA6875B22}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "src\api\Infrastructure\Infrastructure.csproj", "{AE3FC78E-167F-4B6E-88EC-84743EB748B7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Api.Infrastructure", "src\api\Elastic.Documentation.Api.Infrastructure\Elastic.Documentation.Api.Infrastructure.csproj", "{AE3FC78E-167F-4B6E-88EC-84743EB748B7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda", "src\api\AwsLambda\AwsLambda.csproj", "{C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Api.Lambda", "src\api\Elastic.Documentation.Api.Lambda\Elastic.Documentation.Api.Lambda.csproj", "{C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/api/Core/AskAi/AskAiRequest.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiRequest.cs similarity index 70% rename from src/api/Core/AskAi/AskAiRequest.cs rename to src/api/Elastic.Documentation.Api.Core/AskAi/AskAiRequest.cs index 2693fa68f..a6bf5816c 100644 --- a/src/api/Core/AskAi/AskAiRequest.cs +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiRequest.cs @@ -2,6 +2,5 @@ // 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 Api.Core.AskAi; +namespace Elastic.Documentation.Api.Core.AskAi; -public record AskAiRequest(string Message, string? ThreadId); diff --git a/src/api/Core/AskAi/AskAiUsecase.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs similarity index 83% rename from src/api/Core/AskAi/AskAiUsecase.cs rename to src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs index 0840321e3..c7c81cdbd 100644 --- a/src/api/Core/AskAi/AskAiUsecase.cs +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging; -namespace Api.Core.AskAi; +namespace Elastic.Documentation.Api.Core.AskAi; public class AskAiUsecase(IAskAiGateway askAiGateway, ILogger logger) { @@ -14,3 +14,5 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx) return await askAiGateway.AskAi(askAiRequest, ctx); } } + +public record AskAiRequest(string Message, string? ThreadId); diff --git a/src/api/Core/AskAi/IAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/IAskAiGateway.cs similarity index 86% rename from src/api/Core/AskAi/IAskAiGateway.cs rename to src/api/Elastic.Documentation.Api.Core/AskAi/IAskAiGateway.cs index 8d5d68271..236bf94b1 100644 --- a/src/api/Core/AskAi/IAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/IAskAiGateway.cs @@ -2,7 +2,7 @@ // 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 Api.Core.AskAi; +namespace Elastic.Documentation.Api.Core.AskAi; public interface IAskAiGateway { diff --git a/src/api/Core/Core.csproj b/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj similarity index 70% rename from src/api/Core/Core.csproj rename to src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj index 3e6ddc67e..a4ad652a2 100644 --- a/src/api/Core/Core.csproj +++ b/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj @@ -4,8 +4,8 @@ net9.0 enable enable - Api.Core - Api.Core + Elastic.Documentation.Api.Core + Elastic.Documentation.Api.Core diff --git a/src/api/Core/SerializationContext.cs b/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs similarity index 83% rename from src/api/Core/SerializationContext.cs rename to src/api/Elastic.Documentation.Api.Core/SerializationContext.cs index 5504f7155..9ab368b3d 100644 --- a/src/api/Core/SerializationContext.cs +++ b/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs @@ -3,9 +3,9 @@ // See the LICENSE file in the project root for more information using System.Text.Json.Serialization; -using Api.Core.AskAi; +using Elastic.Documentation.Api.Core.AskAi; -namespace Api.Core; +namespace Elastic.Documentation.Api.Core; [JsonSerializable(typeof(AskAiRequest))] diff --git a/src/api/Infrastructure/Adapters/GcpIdTokenProvider.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/GcpIdTokenProvider.cs similarity index 98% rename from src/api/Infrastructure/Adapters/GcpIdTokenProvider.cs rename to src/api/Elastic.Documentation.Api.Infrastructure/Adapters/GcpIdTokenProvider.cs index e6c32297b..14ac83e89 100644 --- a/src/api/Infrastructure/Adapters/GcpIdTokenProvider.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/GcpIdTokenProvider.cs @@ -7,7 +7,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Api.Infrastructure.Adapters; +namespace Elastic.Documentation.Api.Infrastructure.Adapters; // This is a custom implementation to create an ID token for GCP. // Because Google.Api.Auth.OAuth2 is not compatible with AOT diff --git a/src/api/Infrastructure/Adapters/LlmGatewayChatGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/LlmGatewayChatGateway.cs similarity index 95% rename from src/api/Infrastructure/Adapters/LlmGatewayChatGateway.cs rename to src/api/Elastic.Documentation.Api.Infrastructure/Adapters/LlmGatewayChatGateway.cs index 413f3b2de..b3a67fb3d 100644 --- a/src/api/Infrastructure/Adapters/LlmGatewayChatGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/LlmGatewayChatGateway.cs @@ -5,9 +5,9 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; -using Api.Core.AskAi; +using Elastic.Documentation.Api.Core.AskAi; -namespace Api.Infrastructure.Adapters; +namespace Elastic.Documentation.Api.Infrastructure.Adapters; public class LlmGatewayAskAiGateway(HttpClient httpClient, GcpIdTokenProvider tokenProvider, string gcpFunctionUrl) : IAskAiGateway { diff --git a/src/api/Infrastructure/Aws/IParameterProvider.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/IParameterProvider.cs similarity index 85% rename from src/api/Infrastructure/Aws/IParameterProvider.cs rename to src/api/Elastic.Documentation.Api.Infrastructure/Aws/IParameterProvider.cs index 33e4b64e2..c35dac454 100644 --- a/src/api/Infrastructure/Aws/IParameterProvider.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/IParameterProvider.cs @@ -2,7 +2,7 @@ // 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 Api.Infrastructure.Aws; +namespace Elastic.Documentation.Api.Infrastructure.Aws; public interface IParameterProvider { diff --git a/src/api/Infrastructure/Aws/LambdaExtensionParameterProvider.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/LambdaExtensionParameterProvider.cs similarity index 97% rename from src/api/Infrastructure/Aws/LambdaExtensionParameterProvider.cs rename to src/api/Elastic.Documentation.Api.Infrastructure/Aws/LambdaExtensionParameterProvider.cs index 5bf3ab1e0..651e72e19 100644 --- a/src/api/Infrastructure/Aws/LambdaExtensionParameterProvider.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/LambdaExtensionParameterProvider.cs @@ -7,7 +7,7 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; -namespace Api.Infrastructure.Aws; +namespace Elastic.Documentation.Api.Infrastructure.Aws; public class LambdaExtensionParameterProvider(IHttpClientFactory httpClientFactory, ILogger logger) : IParameterProvider { diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Aws/LocalParameterProvider.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/LocalParameterProvider.cs new file mode 100644 index 000000000..c8974fb63 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/LocalParameterProvider.cs @@ -0,0 +1,38 @@ +// 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.Infrastructure.Aws; + +public class LocalParameterProvider : IParameterProvider +{ + public async Task GetParam(string name, bool withDecryption = true, Cancel ctx = default) + { + switch (name) + { + case "/elastic-docs-v3/dev/llm-gateway-service-account": + { + const string envName = "LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH"; + var serviceAccountKeyPath = Environment.GetEnvironmentVariable(envName); + if (string.IsNullOrEmpty(serviceAccountKeyPath)) + throw new ArgumentException($"Environment variable '{envName}' not found."); + if (!File.Exists(serviceAccountKeyPath)) + throw new ArgumentException($"Service account key file not found at '{serviceAccountKeyPath}'."); + var serviceAccountKey = await File.ReadAllTextAsync(serviceAccountKeyPath, ctx); + return serviceAccountKey; + } + case "/elastic-docs-v3/dev/llm-gateway-function-url": + { + const string envName = "LLM_GATEWAY_FUNCTION_URL"; + var value = Environment.GetEnvironmentVariable(envName); + if (string.IsNullOrEmpty(value)) + throw new ArgumentException($"Environment variable '{envName}' not found."); + return value; + } + default: + { + throw new ArgumentException($"Parameter '{name}' not found in {nameof(LocalParameterProvider)}"); + } + } + } +} diff --git a/src/api/Infrastructure/Infrastructure.csproj b/src/api/Elastic.Documentation.Api.Infrastructure/Elastic.Documentation.Api.Infrastructure.csproj similarity index 73% rename from src/api/Infrastructure/Infrastructure.csproj rename to src/api/Elastic.Documentation.Api.Infrastructure/Elastic.Documentation.Api.Infrastructure.csproj index 758c6b223..476c1ba00 100644 --- a/src/api/Infrastructure/Infrastructure.csproj +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Elastic.Documentation.Api.Infrastructure.csproj @@ -4,12 +4,12 @@ net9.0 enable enable - Api.Infrastructure - Api.Infrastructure + Elastic.Documentation.Api.Infrastructure + Elastic.Documentation.Api.Infrastructure - + diff --git a/src/api/Infrastructure/ServicesExtension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs similarity index 92% rename from src/api/Infrastructure/ServicesExtension.cs rename to src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs index c5304ac6f..6f4cd58f8 100644 --- a/src/api/Infrastructure/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs @@ -3,13 +3,13 @@ // See the LICENSE file in the project root for more information using System.ComponentModel.DataAnnotations; -using Api.Core.AskAi; -using Api.Infrastructure.Adapters; -using Api.Infrastructure.Aws; +using Elastic.Documentation.Api.Core.AskAi; +using Elastic.Documentation.Api.Infrastructure.Adapters; +using Elastic.Documentation.Api.Infrastructure.Aws; using Microsoft.Extensions.DependencyInjection; using NetEscapades.EnumGenerators; -namespace Api.Infrastructure; +namespace Elastic.Documentation.Api.Infrastructure; [EnumExtensions] public enum AppEnvironment @@ -55,7 +55,7 @@ private static void AddParameterProvider(IServiceCollection services, AppEnviron } case AppEnvironment.Dev: { - _ = services.AddSingleton(); + _ = services.AddSingleton(); break; } default: diff --git a/src/api/Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs b/src/api/Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs new file mode 100644 index 000000000..8465d8103 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs @@ -0,0 +1,20 @@ +// 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 Elastic.Documentation.Api.Core.AskAi; + +namespace Elastic.Documentation.Api.Lambda; + +public static class AskAiEndpoint +{ + public static void MapAskAiEndpoint(this IEndpointRouteBuilder app) + { + var askAiGroup = app.MapGroup("/ask-ai"); + _ = askAiGroup.MapPost("/stream", async (AskAiRequest askAiRequest, AskAiUsecase askAiUsecase, Cancel ctx) => + { + var stream = await askAiUsecase.AskAi(askAiRequest, ctx); + return Results.Stream(stream, "text/event-stream"); + }); + } +} diff --git a/src/api/AwsLambda/Dockerfile b/src/api/Elastic.Documentation.Api.Lambda/Dockerfile similarity index 100% rename from src/api/AwsLambda/Dockerfile rename to src/api/Elastic.Documentation.Api.Lambda/Dockerfile diff --git a/src/api/AwsLambda/AwsLambda.csproj b/src/api/Elastic.Documentation.Api.Lambda/Elastic.Documentation.Api.Lambda.csproj similarity index 65% rename from src/api/AwsLambda/AwsLambda.csproj rename to src/api/Elastic.Documentation.Api.Lambda/Elastic.Documentation.Api.Lambda.csproj index 2bd3d0f9e..5d49a07a4 100644 --- a/src/api/AwsLambda/AwsLambda.csproj +++ b/src/api/Elastic.Documentation.Api.Lambda/Elastic.Documentation.Api.Lambda.csproj @@ -13,16 +13,17 @@ true true true + true false Linux - - Elastic.Documentation.Lambda.Api + true + $(InterceptorsPreviewNamespaces);Microsoft.AspNetCore.Http.Generated - - + + diff --git a/src/api/AwsLambda/Program.cs b/src/api/Elastic.Documentation.Api.Lambda/Program.cs similarity index 74% rename from src/api/AwsLambda/Program.cs rename to src/api/Elastic.Documentation.Api.Lambda/Program.cs index 134d76aea..2c5044e52 100644 --- a/src/api/AwsLambda/Program.cs +++ b/src/api/Elastic.Documentation.Api.Lambda/Program.cs @@ -5,11 +5,11 @@ using System.Text.Json.Serialization; using Amazon.Lambda.APIGatewayEvents; using Amazon.Lambda.Serialization.SystemTextJson; -using Api.Core; -using Api.Core.AskAi; -using Api.Infrastructure; +using Elastic.Documentation.Api.Core; +using Elastic.Documentation.Api.Infrastructure; +using Elastic.Documentation.Api.Lambda; -var builder = WebApplication.CreateBuilder(args); +var builder = WebApplication.CreateSlimBuilder(args); builder.Services.ConfigureHttpJsonOptions(options => { @@ -23,14 +23,10 @@ var app = builder.Build(); -app.MapPost("/ask-ai/stream", async (AskAiRequest askAiRequest, AskAiUsecase askAiUsecase, Cancel ctx) => -{ - var stream = await askAiUsecase.AskAi(askAiRequest, ctx); - return Results.Stream(stream, "text/event-stream"); -}); +app.MapAskAiEndpoint(); app.Run(); [JsonSerializable(typeof(APIGatewayHttpApiV2ProxyRequest), GenerationMode = JsonSourceGenerationMode.Metadata)] [JsonSerializable(typeof(APIGatewayHttpApiV2ProxyResponse), GenerationMode = JsonSourceGenerationMode.Default)] -internal sealed partial class LambdaJsonSerializerContext : JsonSerializerContext { } +internal sealed partial class LambdaJsonSerializerContext : JsonSerializerContext; diff --git a/src/api/AwsLambda/Properties/launchSettings.json b/src/api/Elastic.Documentation.Api.Lambda/Properties/launchSettings.json similarity index 100% rename from src/api/AwsLambda/Properties/launchSettings.json rename to src/api/Elastic.Documentation.Api.Lambda/Properties/launchSettings.json diff --git a/src/api/AwsLambda/appsettings.Development.json b/src/api/Elastic.Documentation.Api.Lambda/appsettings.Development.json similarity index 100% rename from src/api/AwsLambda/appsettings.Development.json rename to src/api/Elastic.Documentation.Api.Lambda/appsettings.Development.json diff --git a/src/api/AwsLambda/appsettings.json b/src/api/Elastic.Documentation.Api.Lambda/appsettings.json similarity index 100% rename from src/api/AwsLambda/appsettings.json rename to src/api/Elastic.Documentation.Api.Lambda/appsettings.json diff --git a/src/api/Infrastructure/Aws/LocalEnvParameterProvider.cs b/src/api/Infrastructure/Aws/LocalEnvParameterProvider.cs deleted file mode 100644 index 4d6e538f2..000000000 --- a/src/api/Infrastructure/Aws/LocalEnvParameterProvider.cs +++ /dev/null @@ -1,26 +0,0 @@ -// 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 Microsoft.Extensions.Configuration; - -namespace Api.Infrastructure.Aws; - -public class LocalEnvParameterProvider : IParameterProvider -{ - public Task GetParam(string name, bool withDecryption = true, Cancel ctx = default) - { - var env = name switch - { - "/elastic-docs-v3/dev/llm-gateway-service-account" => "LLM_GATEWAY_SERVICE_ACCOUNT", - "/elastic-docs-v3/dev/llm-gateway-function-url" => "LLM_GATEWAY_FUNCTION_URL", - _ => throw new ArgumentOutOfRangeException(nameof(name), name, null) - }; - var value = Environment.GetEnvironmentVariable(env); - - if (string.IsNullOrEmpty(value)) - throw new ArgumentException($"Environment variable '{env}' not found."); - - return Task.FromResult(value); - } -} diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 36a91a592..bd1404c1a 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -7,10 +7,10 @@ using System.Runtime.InteropServices; using System.Text; using System.Text.Json; -using Api.Core; -using Api.Core.AskAi; -using Api.Infrastructure.Adapters; using Documentation.Builder.Diagnostics.LiveMode; +using Elastic.Documentation.Api.Core; +using Elastic.Documentation.Api.Core.AskAi; +using Elastic.Documentation.Api.Infrastructure.Adapters; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Site.FileProviders; @@ -32,10 +32,12 @@ public class DocumentationWebHost private readonly IHostedService _hostedService; private readonly IFileSystem _writeFileSystem; + private readonly ILoggerFactory _logFactory; public DocumentationWebHost(ILoggerFactory logFactory, string? path, int port, IFileSystem readFs, IFileSystem writeFs, VersionsConfiguration versionsConfig) { + _logFactory = logFactory; _writeFileSystem = writeFs; var builder = WebApplication.CreateSlimBuilder(); DocumentationTooling.CreateServiceCollection(builder.Services, LogLevel.Information); @@ -224,14 +226,14 @@ private static IResult LiveReloadHtml(string content, Encoding? encoding = null, return Results.Content(content, "text/html", encoding, statusCode); } - private static async Task ProxyChatRequest(HttpContext context, CancellationToken ctx) + private async Task ProxyChatRequest(HttpContext context, CancellationToken ctx) { // Read the frontend request body var requestBody = await new StreamReader(context.Request.Body).ReadToEndAsync(ctx); var askAiRequest = JsonSerializer.Deserialize(requestBody, ApiJsonContext.Default.AskAiRequest); // Load GCP service account credentials - var serviceAccountKeyPath = Environment.GetEnvironmentVariable("LLM_GATEWAY_GCP_SERVICE_ACCOUNT_KEY_PATH") + var serviceAccountKeyPath = Environment.GetEnvironmentVariable("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH") ?? "service-account-key.json"; if (!File.Exists(serviceAccountKeyPath)) @@ -242,7 +244,7 @@ private static async Task ProxyChatRequest(HttpContext context, Cancell } // Get GCP function URL - var gcpFunctionUrl = Environment.GetEnvironmentVariable("LLM_GATEWAY_GCP_FUNCTION_URL"); + var gcpFunctionUrl = Environment.GetEnvironmentVariable("LLM_GATEWAY_FUNCTION_URL"); if (string.IsNullOrEmpty(gcpFunctionUrl)) { context.Response.StatusCode = 500; @@ -263,7 +265,8 @@ await File.ReadAllTextAsync(serviceAccountKeyPath, ctx), gcpIdTokenProvider, gcpFunctionUrl ); - var askAiUsecase = new AskAiUsecase(llmGatewayAskAiGateway); + + var askAiUsecase = new AskAiUsecase(llmGatewayAskAiGateway, _logFactory.CreateLogger()); if (askAiRequest == null) return Results.BadRequest("Invalid chat request."); diff --git a/src/tooling/docs-builder/docs-builder.csproj b/src/tooling/docs-builder/docs-builder.csproj index 937ef70b5..d0b7388a4 100644 --- a/src/tooling/docs-builder/docs-builder.csproj +++ b/src/tooling/docs-builder/docs-builder.csproj @@ -26,8 +26,8 @@ - - + + From e9ed083256f844693da58ef6cca7b8e075a7d67d Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 12:56:15 +0200 Subject: [PATCH 04/20] Fix editorconfig path --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 56bbd9043..6e28e5be5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -247,7 +247,7 @@ dotnet_diagnostic.IDE0305.severity = none dotnet_diagnostic.IDE0072.severity = none -[src/api/AwsLambda/**/*.cs] +[src/api/Elastic.Documentation.Api.Lambda/**/*.cs] dotnet_diagnostic.IL3050.severity = none dotnet_diagnostic.IL2026.severity = none From 9217ac0a391ad92372aa881a28cde9bbc66d0d7e Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 12:58:02 +0200 Subject: [PATCH 05/20] Refactor editorconfig --- .editorconfig | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.editorconfig b/.editorconfig index 6e28e5be5..6e589914b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -246,17 +246,7 @@ dotnet_diagnostic.IDE0305.severity = none # CS8509 already warns dotnet_diagnostic.IDE0072.severity = none - -[src/api/Elastic.Documentation.Api.Lambda/**/*.cs] -dotnet_diagnostic.IL3050.severity = none -dotnet_diagnostic.IL2026.severity = none - - -[DocumentationWebHost.cs] -dotnet_diagnostic.IL3050.severity = none -dotnet_diagnostic.IL2026.severity = none - -[StaticWebHost.cs] +[{src/api/Elastic.Documentation.Api.Lambda/**/*.cs,DocumentationWebHost.cs,StaticWebHost.cs}] dotnet_diagnostic.IL3050.severity = none dotnet_diagnostic.IL2026.severity = none From 39dee99489d076ccdb9a42dfb99dd8e9d36c043b Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 13:11:16 +0200 Subject: [PATCH 06/20] Revert "Refactor editorconfig" This reverts commit 9217ac0a391ad92372aa881a28cde9bbc66d0d7e. --- .editorconfig | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 6e589914b..6e28e5be5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -246,7 +246,17 @@ dotnet_diagnostic.IDE0305.severity = none # CS8509 already warns dotnet_diagnostic.IDE0072.severity = none -[{src/api/Elastic.Documentation.Api.Lambda/**/*.cs,DocumentationWebHost.cs,StaticWebHost.cs}] + +[src/api/Elastic.Documentation.Api.Lambda/**/*.cs] +dotnet_diagnostic.IL3050.severity = none +dotnet_diagnostic.IL2026.severity = none + + +[DocumentationWebHost.cs] +dotnet_diagnostic.IL3050.severity = none +dotnet_diagnostic.IL2026.severity = none + +[StaticWebHost.cs] dotnet_diagnostic.IL3050.severity = none dotnet_diagnostic.IL2026.severity = none From 1a3317e8c4657ffa422a1542992d2d83c554f9c4 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 13:12:22 +0200 Subject: [PATCH 07/20] Fix path --- src/api/Elastic.Documentation.Api.Lambda/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/Elastic.Documentation.Api.Lambda/Dockerfile b/src/api/Elastic.Documentation.Api.Lambda/Dockerfile index 654c52683..07ac09d4a 100644 --- a/src/api/Elastic.Documentation.Api.Lambda/Dockerfile +++ b/src/api/Elastic.Documentation.Api.Lambda/Dockerfile @@ -24,4 +24,4 @@ RUN arch=$TARGETARCH \ && if [ "$arch" = "amd64" ]; then arch="x64"; fi \ && echo $TARGETOS-$arch > /tmp/rid -RUN dotnet publish src/api/AwsLambda -r linux-x64 -c Release +RUN dotnet publish src/api/Elastic.Documentation.Api.Lambda -r linux-x64 -c Release From 9eb6ac7bc37608bd200762560bf6e0c3f0a4ed2b Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 13:19:44 +0200 Subject: [PATCH 08/20] Fix editorconfig glob --- .editorconfig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.editorconfig b/.editorconfig index 6e28e5be5..578ce1883 100644 --- a/.editorconfig +++ b/.editorconfig @@ -246,12 +246,10 @@ dotnet_diagnostic.IDE0305.severity = none # CS8509 already warns dotnet_diagnostic.IDE0072.severity = none - -[src/api/Elastic.Documentation.Api.Lambda/**/*.cs] +[src/api/Elastic.Documentation.Api.Lambda/**.cs] dotnet_diagnostic.IL3050.severity = none dotnet_diagnostic.IL2026.severity = none - [DocumentationWebHost.cs] dotnet_diagnostic.IL3050.severity = none dotnet_diagnostic.IL2026.severity = none From 6a3727b26e9a60ae97a569d6cc01ee547c3304f3 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 13:28:46 +0200 Subject: [PATCH 09/20] Adjust folder structure --- .../LlmGatewayAskAiGateway.cs} | 3 ++- .../{Adapters => Gcp}/GcpIdTokenProvider.cs | 2 +- .../ServicesExtension.cs | 3 ++- src/tooling/docs-builder/Http/DocumentationWebHost.cs | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) rename src/api/Elastic.Documentation.Api.Infrastructure/Adapters/{LlmGatewayChatGateway.cs => AskAi/LlmGatewayAskAiGateway.cs} (95%) rename src/api/Elastic.Documentation.Api.Infrastructure/{Adapters => Gcp}/GcpIdTokenProvider.cs (98%) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/LlmGatewayChatGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs similarity index 95% rename from src/api/Elastic.Documentation.Api.Infrastructure/Adapters/LlmGatewayChatGateway.cs rename to src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs index b3a67fb3d..e665d7ca4 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/LlmGatewayChatGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs @@ -6,8 +6,9 @@ using System.Text.Json; using System.Text.Json.Serialization; using Elastic.Documentation.Api.Core.AskAi; +using Elastic.Documentation.Api.Infrastructure.Gcp; -namespace Elastic.Documentation.Api.Infrastructure.Adapters; +namespace Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; public class LlmGatewayAskAiGateway(HttpClient httpClient, GcpIdTokenProvider tokenProvider, string gcpFunctionUrl) : IAskAiGateway { diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/GcpIdTokenProvider.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Gcp/GcpIdTokenProvider.cs similarity index 98% rename from src/api/Elastic.Documentation.Api.Infrastructure/Adapters/GcpIdTokenProvider.cs rename to src/api/Elastic.Documentation.Api.Infrastructure/Gcp/GcpIdTokenProvider.cs index 14ac83e89..5345d9368 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/GcpIdTokenProvider.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Gcp/GcpIdTokenProvider.cs @@ -7,7 +7,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Elastic.Documentation.Api.Infrastructure.Adapters; +namespace Elastic.Documentation.Api.Infrastructure.Gcp; // This is a custom implementation to create an ID token for GCP. // Because Google.Api.Auth.OAuth2 is not compatible with AOT diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs index 6f4cd58f8..f9b5fc744 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs @@ -4,8 +4,9 @@ using System.ComponentModel.DataAnnotations; using Elastic.Documentation.Api.Core.AskAi; -using Elastic.Documentation.Api.Infrastructure.Adapters; +using Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; using Elastic.Documentation.Api.Infrastructure.Aws; +using Elastic.Documentation.Api.Infrastructure.Gcp; using Microsoft.Extensions.DependencyInjection; using NetEscapades.EnumGenerators; diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index bd1404c1a..918ee4fb4 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -10,7 +10,8 @@ using Documentation.Builder.Diagnostics.LiveMode; using Elastic.Documentation.Api.Core; using Elastic.Documentation.Api.Core.AskAi; -using Elastic.Documentation.Api.Infrastructure.Adapters; +using Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; +using Elastic.Documentation.Api.Infrastructure.Gcp; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Site.FileProviders; From de24b8ae19efe876d53d864e5fff2fcb1b56dcd7 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 14:16:07 +0200 Subject: [PATCH 10/20] Refactor how DocumentationWebhost is reusing the proxy --- .../SearchOrAskAi/useLlmGateway.ts | 2 +- .../SerializationContext.cs | 2 +- .../ServicesExtension.cs | 12 ++- .../AskAiEndpoint.cs | 4 +- .../Program.cs | 13 +--- .../docs-builder/Http/DocumentationWebHost.cs | 74 ++++++------------- 6 files changed, 39 insertions(+), 68 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts index 3bd4143a1..4ceb6c5ef 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts @@ -142,7 +142,7 @@ export const useLlmGateway = (props: Props): UseLlmGatewayResponse => { ) const { sendMessage, abort } = useFetchEventSource({ - apiEndpoint: '/chat', + apiEndpoint: '/_api/v1/ask-ai/stream', onMessage, onError: (error) => { setError(error) diff --git a/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs b/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs index 9ab368b3d..07a92d26a 100644 --- a/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs +++ b/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs @@ -9,5 +9,5 @@ namespace Elastic.Documentation.Api.Core; [JsonSerializable(typeof(AskAiRequest))] -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] public partial class ApiJsonContext : JsonSerializerContext; diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs index f9b5fc744..887c88436 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.ComponentModel.DataAnnotations; +using Elastic.Documentation.Api.Core; using Elastic.Documentation.Api.Core.AskAi; using Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; using Elastic.Documentation.Api.Infrastructure.Aws; @@ -23,16 +24,21 @@ public enum AppEnvironment public static class ServicesExtension { - public static void AddUsecases(this IServiceCollection services, string? appEnvironment) => - AddUsecases( + public static void AddApiUsecases(this IServiceCollection services, string? appEnvironment) => + AddApiUsecases( services, AppEnvironmentExtensions.TryParse(appEnvironment, out var parsedEnvironment, true) ? parsedEnvironment : AppEnvironment.Dev ); - private static void AddUsecases(this IServiceCollection services, AppEnvironment appEnvironment) + private static void AddApiUsecases(this IServiceCollection services, AppEnvironment appEnvironment) { + _ = services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default); + }); + _ = services.AddHttpClient(); AddParameterProvider(services, appEnvironment); AddAskAiUsecases(services, appEnvironment); } diff --git a/src/api/Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs b/src/api/Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs index 8465d8103..07c9daacd 100644 --- a/src/api/Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs +++ b/src/api/Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs @@ -8,9 +8,9 @@ namespace Elastic.Documentation.Api.Lambda; public static class AskAiEndpoint { - public static void MapAskAiEndpoint(this IEndpointRouteBuilder app) + public static void MapAskAiEndpoint(this RouteGroupBuilder parentGroup) { - var askAiGroup = app.MapGroup("/ask-ai"); + var askAiGroup = parentGroup.MapGroup("/ask-ai"); _ = askAiGroup.MapPost("/stream", async (AskAiRequest askAiRequest, AskAiUsecase askAiUsecase, Cancel ctx) => { var stream = await askAiUsecase.AskAi(askAiRequest, ctx); diff --git a/src/api/Elastic.Documentation.Api.Lambda/Program.cs b/src/api/Elastic.Documentation.Api.Lambda/Program.cs index 2c5044e52..7ea2161aa 100644 --- a/src/api/Elastic.Documentation.Api.Lambda/Program.cs +++ b/src/api/Elastic.Documentation.Api.Lambda/Program.cs @@ -5,25 +5,18 @@ using System.Text.Json.Serialization; using Amazon.Lambda.APIGatewayEvents; using Amazon.Lambda.Serialization.SystemTextJson; -using Elastic.Documentation.Api.Core; using Elastic.Documentation.Api.Infrastructure; using Elastic.Documentation.Api.Lambda; var builder = WebApplication.CreateSlimBuilder(args); -builder.Services.ConfigureHttpJsonOptions(options => -{ - options.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default); -}); - -builder.Services.AddHttpClient(); -builder.Services.AddUsecases(Environment.GetEnvironmentVariable("APP_ENVIRONMENT")); - builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi, new SourceGeneratorLambdaJsonSerializer()); +builder.Services.AddApiUsecases(Environment.GetEnvironmentVariable("APP_ENVIRONMENT")); var app = builder.Build(); -app.MapAskAiEndpoint(); +var v1 = app.MapGroup("/v1"); +v1.MapAskAiEndpoint(); app.Run(); diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 918ee4fb4..3b8314b6e 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -10,6 +10,7 @@ using Documentation.Builder.Diagnostics.LiveMode; using Elastic.Documentation.Api.Core; using Elastic.Documentation.Api.Core.AskAi; +using Elastic.Documentation.Api.Infrastructure; using Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; using Elastic.Documentation.Api.Infrastructure.Gcp; using Elastic.Documentation.Configuration; @@ -33,16 +34,14 @@ public class DocumentationWebHost private readonly IHostedService _hostedService; private readonly IFileSystem _writeFileSystem; - private readonly ILoggerFactory _logFactory; public DocumentationWebHost(ILoggerFactory logFactory, string? path, int port, IFileSystem readFs, IFileSystem writeFs, VersionsConfiguration versionsConfig) { - _logFactory = logFactory; _writeFileSystem = writeFs; var builder = WebApplication.CreateSlimBuilder(); + builder.Services.AddApiUsecases("dev"); DocumentationTooling.CreateServiceCollection(builder.Services, LogLevel.Information); - _ = builder.Logging .AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Error) .AddFilter("Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware", LogLevel.Error) @@ -101,6 +100,23 @@ private void SetUpRoutes() _ = _webApplication .UseLiveReloadWithManualScriptInjection(_webApplication.Lifetime) .UseDeveloperExceptionPage(new DeveloperExceptionPageOptions()) + .Use(async (context, next) => + { + try + { + await next(context); + } + catch (Exception ex) + { + Console.WriteLine($"[UNHANDLED EXCEPTION] {ex.GetType().Name}: {ex.Message}"); + Console.WriteLine($"[STACK TRACE] {ex.StackTrace}"); + if (ex.InnerException != null) + { + Console.WriteLine($"[INNER EXCEPTION] {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + } + throw; // Re-throw to let ASP.NET Core handle it + } + }) .UseStaticFiles( new StaticFileOptions { @@ -118,8 +134,7 @@ private void SetUpRoutes() _ = _webApplication.MapGet("/api/{**slug}", (string slug, ReloadableGeneratorState holder, Cancel ctx) => ServeApiFile(holder, slug, ctx)); - _ = _webApplication.MapPost("/chat", async (HttpContext context, Cancel ctx) => - await ProxyChatRequest(context, ctx)); + _ = _webApplication.MapPost("/_api/v1/ask-ai/stream", ProxyChatRequest); _ = _webApplication.MapGet("{**slug}", (string slug, ReloadableGeneratorState holder, Cancel ctx) => ServeDocumentationFile(holder, slug, ctx)); @@ -227,52 +242,9 @@ private static IResult LiveReloadHtml(string content, Encoding? encoding = null, return Results.Content(content, "text/html", encoding, statusCode); } - private async Task ProxyChatRequest(HttpContext context, CancellationToken ctx) + private static async Task ProxyChatRequest(AskAiRequest request, AskAiUsecase usecase, Cancel ctx) { - // Read the frontend request body - var requestBody = await new StreamReader(context.Request.Body).ReadToEndAsync(ctx); - var askAiRequest = JsonSerializer.Deserialize(requestBody, ApiJsonContext.Default.AskAiRequest); - - // Load GCP service account credentials - var serviceAccountKeyPath = Environment.GetEnvironmentVariable("LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH") - ?? "service-account-key.json"; - - if (!File.Exists(serviceAccountKeyPath)) - { - context.Response.StatusCode = 500; - await context.Response.WriteAsync("GCP credentials not configured", cancellationToken: ctx); - return Results.Empty; - } - - // Get GCP function URL - var gcpFunctionUrl = Environment.GetEnvironmentVariable("LLM_GATEWAY_FUNCTION_URL"); - if (string.IsNullOrEmpty(gcpFunctionUrl)) - { - context.Response.StatusCode = 500; - await context.Response.WriteAsync("GCP function URL not configured", cancellationToken: ctx); - return Results.Empty; - } - - var functionUri = new Uri(gcpFunctionUrl); - var audienceUrl = $"{functionUri.Scheme}://{functionUri.Host}"; - var httpClient = new HttpClient(); - var gcpIdTokenProvider = new GcpIdTokenProvider( - httpClient, - await File.ReadAllTextAsync(serviceAccountKeyPath, ctx), - audienceUrl - ); - var llmGatewayAskAiGateway = new LlmGatewayAskAiGateway( - httpClient, - gcpIdTokenProvider, - gcpFunctionUrl - ); - - var askAiUsecase = new AskAiUsecase(llmGatewayAskAiGateway, _logFactory.CreateLogger()); - - if (askAiRequest == null) - return Results.BadRequest("Invalid chat request."); - - var responseStream = await askAiUsecase.AskAi(askAiRequest, ctx); - return Results.Stream(responseStream, "text/event-stream"); + var stream = await usecase.AskAi(request, ctx); + return Results.Stream(stream, "text/event-stream"); } } From 1d6fc4a985ad7dd94bb433c293532ffa65429735 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 14:16:56 +0200 Subject: [PATCH 11/20] Revert changes to stepper --- docs/syntax/stepper.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/syntax/stepper.md b/docs/syntax/stepper.md index f7d156d67..cf52aad6d 100644 --- a/docs/syntax/stepper.md +++ b/docs/syntax/stepper.md @@ -12,7 +12,7 @@ By default every step title is a link with a generated anchor. You can override :::::{stepper} ::::{step} Install -First install the {{foo}} dependencies. +First install the dependencies. ```shell npm install ``` From 90efcac791ec66f15d250cab0de8c9298bdc916f Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 14:17:27 +0200 Subject: [PATCH 12/20] Remove empty file --- .../Elastic.Documentation.Api.Core/AskAi/AskAiRequest.cs | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 src/api/Elastic.Documentation.Api.Core/AskAi/AskAiRequest.cs diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiRequest.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiRequest.cs deleted file mode 100644 index a6bf5816c..000000000 --- a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -// 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; - From 46676a9b555811cc381703a85056501665cf0f87 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 14:57:44 +0200 Subject: [PATCH 13/20] Refactor dependency resolution --- .../Adapters/AskAi/LlmGatewayAskAiGateway.cs | 5 +- .../Gcp/GcpIdTokenProvider.cs | 9 +-- .../ServicesExtension.cs | 71 ++++++++++--------- 3 files changed, 46 insertions(+), 39 deletions(-) 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 e665d7ca4..0c4460de1 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs @@ -7,16 +7,17 @@ using System.Text.Json.Serialization; using Elastic.Documentation.Api.Core.AskAi; using Elastic.Documentation.Api.Infrastructure.Gcp; +using Microsoft.Extensions.Options; namespace Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; -public class LlmGatewayAskAiGateway(HttpClient httpClient, GcpIdTokenProvider tokenProvider, string gcpFunctionUrl) : IAskAiGateway +public class LlmGatewayAskAiGateway(HttpClient httpClient, GcpIdTokenProvider tokenProvider, IOptionsSnapshot options) : IAskAiGateway { public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) { var llmGatewayRequest = LlmGatewayRequest.CreateFromQuestion(askAiRequest.Message, askAiRequest.ThreadId); var requestBody = JsonSerializer.Serialize(llmGatewayRequest, LlmGatewayContext.Default.LlmGatewayRequest); - var request = new HttpRequestMessage(HttpMethod.Post, gcpFunctionUrl) + var request = new HttpRequestMessage(HttpMethod.Post, options.Value.FunctionUrl) { Content = new StringContent(requestBody, Encoding.UTF8, "application/json") }; diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Gcp/GcpIdTokenProvider.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Gcp/GcpIdTokenProvider.cs index 5345d9368..3c06e6345 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Gcp/GcpIdTokenProvider.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Gcp/GcpIdTokenProvider.cs @@ -6,17 +6,18 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; namespace Elastic.Documentation.Api.Infrastructure.Gcp; // This is a custom implementation to create an ID token for GCP. // Because Google.Api.Auth.OAuth2 is not compatible with AOT -public class GcpIdTokenProvider(HttpClient httpClient, string gcpServiceAccount, string targetAudience) +public class GcpIdTokenProvider(HttpClient httpClient, IOptionsSnapshot options) { public async Task GenerateIdTokenAsync(Cancel cancellationToken = default) { // Read and parse service account key file using System.Text.Json source generation (AOT compatible) - var serviceAccount = JsonSerializer.Deserialize(gcpServiceAccount, GcpJsonContext.Default.ServiceAccountKey); + var serviceAccount = JsonSerializer.Deserialize(options.Value.ServiceAccount, GcpJsonContext.Default.ServiceAccountKey); // Create JWT header var header = new JwtHeader("RS256", "JWT", serviceAccount.PrivateKeyId); @@ -31,7 +32,7 @@ public async Task GenerateIdTokenAsync(Cancel cancellationToken = defaul "https://oauth2.googleapis.com/token", now, now + 300, // 5 minutes - targetAudience + options.Value.TargetAudience ); var payloadJson = JsonSerializer.Serialize(payload, GcpJsonContext.Default.JwtPayload); @@ -58,7 +59,7 @@ public async Task GenerateIdTokenAsync(Cancel cancellationToken = defaul var jwt = $"{message}.{signatureBase64}"; // Exchange JWT for ID token - return await ExchangeJwtForIdToken(jwt, targetAudience, cancellationToken); + return await ExchangeJwtForIdToken(jwt, options.Value.TargetAudience, cancellationToken); } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs index 887c88436..7fb55bd58 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Api.Infrastructure.Aws; using Elastic.Documentation.Api.Infrastructure.Gcp; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using NetEscapades.EnumGenerators; namespace Elastic.Documentation.Api.Infrastructure; @@ -22,15 +23,35 @@ public enum AppEnvironment [Display(Name = "prod")] Prod } +public class LlmGatewayOptions +{ + public string ServiceAccount { get; set; } = string.Empty; + public string FunctionUrl { get; set; } = string.Empty; + public string TargetAudience { get; set; } = string.Empty; +} + public static class ServicesExtension { - public static void AddApiUsecases(this IServiceCollection services, string? appEnvironment) => - AddApiUsecases( - services, - AppEnvironmentExtensions.TryParse(appEnvironment, out var parsedEnvironment, true) - ? parsedEnvironment - : AppEnvironment.Dev - ); + public static void AddApiUsecases(this IServiceCollection services, string? appEnvironment) + { + if (AppEnvironmentExtensions.TryParse(appEnvironment, out var parsedEnvironment, true)) + { + AddApiUsecases( + services, + parsedEnvironment + ); + } + else + { + var logger = services.BuildServiceProvider().GetRequiredService(); + logger.LogWarning("Unable to parse environment {Environment} into AppEnvironment. Using default AppEnvironment.Dev", appEnvironment); + AddApiUsecases( + services, + AppEnvironment.Dev + ); + } + } + private static void AddApiUsecases(this IServiceCollection services, AppEnvironment appEnvironment) { @@ -40,7 +61,7 @@ private static void AddApiUsecases(this IServiceCollection services, AppEnvironm }); _ = services.AddHttpClient(); AddParameterProvider(services, appEnvironment); - AddAskAiUsecases(services, appEnvironment); + AddAskAiUsecase(services, appEnvironment); } // https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html @@ -73,38 +94,22 @@ private static void AddParameterProvider(IServiceCollection services, AppEnviron } } - private static void AddAskAiUsecases(IServiceCollection services, AppEnvironment appEnvironment) + private static void AddAskAiUsecase(IServiceCollection services, AppEnvironment appEnvironment) { - _ = services.AddScoped(serviceProvider => + _ = services.Configure(options => { - var httpClient = serviceProvider.GetRequiredService(); + var serviceProvider = services.BuildServiceProvider(); var parameterProvider = serviceProvider.GetRequiredService(); var appEnvString = appEnvironment.ToStringFast(true); - var serviceAccount = parameterProvider - .GetParam($"/elastic-docs-v3/{appEnvString}/llm-gateway-service-account") - .GetAwaiter() - .GetResult(); - var functionUrl = parameterProvider - .GetParam($"/elastic-docs-v3/{appEnvString}/llm-gateway-function-url") - .GetAwaiter() - .GetResult(); - - var functionUri = new Uri(functionUrl); - var targetAudience = $"{functionUri.Scheme}://{functionUri.Host}"; - - return new GcpIdTokenProvider(httpClient, serviceAccount, targetAudience); - }); + options.ServiceAccount = parameterProvider.GetParam($"/elastic-docs-v3/{appEnvString}/llm-gateway-service-account").GetAwaiter().GetResult(); + options.FunctionUrl = parameterProvider.GetParam($"/elastic-docs-v3/{appEnvString}/llm-gateway-function-url").GetAwaiter().GetResult(); - _ = services.AddScoped>(serviceProvider => - { - var parameterProvider = serviceProvider.GetRequiredService(); - var tokenProvider = serviceProvider.GetRequiredService(); - var httpClient = serviceProvider.GetRequiredService(); - var functionUrl = parameterProvider.GetParam("/elastic-docs-v3/dev/llm-gateway-function-url").GetAwaiter().GetResult(); - return new LlmGatewayAskAiGateway(httpClient, tokenProvider, functionUrl); + var functionUri = new Uri(options.FunctionUrl); + options.TargetAudience = $"{functionUri.Scheme}://{functionUri.Host}"; }); - + _ = services.AddScoped(); + _ = services.AddScoped, LlmGatewayAskAiGateway>(); _ = services.AddScoped(); } } From 2d42283e06900d5cc619d85552804fe32ce63197 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 15:45:49 +0200 Subject: [PATCH 14/20] Refactor mapping --- .../MappingsExstension.cs} | 14 ++++++++++---- .../ServicesExtension.cs | 8 ++++---- .../Elastic.Documentation.Api.Lambda/Program.cs | 5 ++--- .../docs-builder/Http/DocumentationWebHost.cs | 12 ++++-------- 4 files changed, 20 insertions(+), 19 deletions(-) rename src/api/{Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs => Elastic.Documentation.Api.Infrastructure/MappingsExstension.cs} (54%) diff --git a/src/api/Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExstension.cs similarity index 54% rename from src/api/Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs rename to src/api/Elastic.Documentation.Api.Infrastructure/MappingsExstension.cs index 07c9daacd..92e9daccf 100644 --- a/src/api/Elastic.Documentation.Api.Lambda/AskAiEndpoint.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExstension.cs @@ -3,14 +3,20 @@ // See the LICENSE file in the project root for more information using Elastic.Documentation.Api.Core.AskAi; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; -namespace Elastic.Documentation.Api.Lambda; +namespace Elastic.Documentation.Api.Infrastructure; -public static class AskAiEndpoint +public static class MappingsExtension { - public static void MapAskAiEndpoint(this RouteGroupBuilder parentGroup) + public static void MapElasticDocsApiEndpoints(this IEndpointRouteBuilder group) => + MapAskAiEndpoint(group); + + private static void MapAskAiEndpoint(IEndpointRouteBuilder group) { - var askAiGroup = parentGroup.MapGroup("/ask-ai"); + var askAiGroup = group.MapGroup("/ask-ai"); _ = askAiGroup.MapPost("/stream", async (AskAiRequest askAiRequest, AskAiUsecase askAiUsecase, Cancel ctx) => { var stream = await askAiUsecase.AskAi(askAiRequest, ctx); diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs index 7fb55bd58..4a5d22f3e 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs @@ -32,11 +32,11 @@ public class LlmGatewayOptions public static class ServicesExtension { - public static void AddApiUsecases(this IServiceCollection services, string? appEnvironment) + public static void AddElasticDocsApiUsecases(this IServiceCollection services, string? appEnvironment) { if (AppEnvironmentExtensions.TryParse(appEnvironment, out var parsedEnvironment, true)) { - AddApiUsecases( + AddElasticDocsApiUsecases( services, parsedEnvironment ); @@ -45,7 +45,7 @@ public static void AddApiUsecases(this IServiceCollection services, string? appE { var logger = services.BuildServiceProvider().GetRequiredService(); logger.LogWarning("Unable to parse environment {Environment} into AppEnvironment. Using default AppEnvironment.Dev", appEnvironment); - AddApiUsecases( + AddElasticDocsApiUsecases( services, AppEnvironment.Dev ); @@ -53,7 +53,7 @@ public static void AddApiUsecases(this IServiceCollection services, string? appE } - private static void AddApiUsecases(this IServiceCollection services, AppEnvironment appEnvironment) + private static void AddElasticDocsApiUsecases(this IServiceCollection services, AppEnvironment appEnvironment) { _ = services.ConfigureHttpJsonOptions(options => { diff --git a/src/api/Elastic.Documentation.Api.Lambda/Program.cs b/src/api/Elastic.Documentation.Api.Lambda/Program.cs index 7ea2161aa..8ea055886 100644 --- a/src/api/Elastic.Documentation.Api.Lambda/Program.cs +++ b/src/api/Elastic.Documentation.Api.Lambda/Program.cs @@ -6,17 +6,16 @@ using Amazon.Lambda.APIGatewayEvents; using Amazon.Lambda.Serialization.SystemTextJson; using Elastic.Documentation.Api.Infrastructure; -using Elastic.Documentation.Api.Lambda; var builder = WebApplication.CreateSlimBuilder(args); builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi, new SourceGeneratorLambdaJsonSerializer()); -builder.Services.AddApiUsecases(Environment.GetEnvironmentVariable("APP_ENVIRONMENT")); +builder.Services.AddElasticDocsApiUsecases(Environment.GetEnvironmentVariable("APP_ENVIRONMENT")); var app = builder.Build(); var v1 = app.MapGroup("/v1"); -v1.MapAskAiEndpoint(); +v1.MapElasticDocsApiEndpoints(); app.Run(); diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 3b8314b6e..bcca91319 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -40,7 +40,7 @@ public DocumentationWebHost(ILoggerFactory logFactory, string? path, int port, I { _writeFileSystem = writeFs; var builder = WebApplication.CreateSlimBuilder(); - builder.Services.AddApiUsecases("dev"); + builder.Services.AddElasticDocsApiUsecases("dev"); DocumentationTooling.CreateServiceCollection(builder.Services, LogLevel.Information); _ = builder.Logging .AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Error) @@ -134,7 +134,9 @@ private void SetUpRoutes() _ = _webApplication.MapGet("/api/{**slug}", (string slug, ReloadableGeneratorState holder, Cancel ctx) => ServeApiFile(holder, slug, ctx)); - _ = _webApplication.MapPost("/_api/v1/ask-ai/stream", ProxyChatRequest); + + var apiV1 = _webApplication.MapGroup("/_api/v1"); + apiV1.MapElasticDocsApiEndpoints(); _ = _webApplication.MapGet("{**slug}", (string slug, ReloadableGeneratorState holder, Cancel ctx) => ServeDocumentationFile(holder, slug, ctx)); @@ -241,10 +243,4 @@ private static IResult LiveReloadHtml(string content, Encoding? encoding = null, return Results.Content(content, "text/html", encoding, statusCode); } - - private static async Task ProxyChatRequest(AskAiRequest request, AskAiUsecase usecase, Cancel ctx) - { - var stream = await usecase.AskAi(request, ctx); - return Results.Stream(stream, "text/event-stream"); - } } From b4dfc60b0b7374d6af0f4999bdbccd03cf850323 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 15:58:03 +0200 Subject: [PATCH 15/20] Fix logging --- .../ServicesExtension.cs | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs index 4a5d22f3e..3bb7ecf15 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs @@ -32,23 +32,24 @@ public class LlmGatewayOptions public static class ServicesExtension { + private static ILogger? GetLogger(IServiceCollection services) + { + using var serviceProvider = services.BuildServiceProvider(); + var loggerFactory = serviceProvider.GetService(); + return loggerFactory?.CreateLogger(typeof(ServicesExtension)); + } + public static void AddElasticDocsApiUsecases(this IServiceCollection services, string? appEnvironment) { if (AppEnvironmentExtensions.TryParse(appEnvironment, out var parsedEnvironment, true)) { - AddElasticDocsApiUsecases( - services, - parsedEnvironment - ); + AddElasticDocsApiUsecases(services, parsedEnvironment); } else { - var logger = services.BuildServiceProvider().GetRequiredService(); - logger.LogWarning("Unable to parse environment {Environment} into AppEnvironment. Using default AppEnvironment.Dev", appEnvironment); - AddElasticDocsApiUsecases( - services, - AppEnvironment.Dev - ); + var logger = GetLogger(services); + logger?.LogWarning("Unable to parse environment {AppEnvironment} into AppEnvironment. Using default AppEnvironment.Dev", appEnvironment); + AddElasticDocsApiUsecases(services, AppEnvironment.Dev); } } @@ -67,12 +68,15 @@ private static void AddElasticDocsApiUsecases(this IServiceCollection services, // https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html private static void AddParameterProvider(IServiceCollection services, AppEnvironment appEnvironment) { + var logger = GetLogger(services); + switch (appEnvironment) { case AppEnvironment.Prod: case AppEnvironment.Staging: case AppEnvironment.Edge: { + logger?.LogInformation("Configuring LambdaExtensionParameterProvider for environment {AppEnvironment}", appEnvironment); _ = services.AddHttpClient(LambdaExtensionParameterProvider.HttpClientName, client => { client.BaseAddress = new Uri("http://localhost:2773"); @@ -83,6 +87,7 @@ private static void AddParameterProvider(IServiceCollection services, AppEnviron } case AppEnvironment.Dev: { + logger?.LogInformation("Configuring LocalParameterProvider for environment {AppEnvironment}", appEnvironment); _ = services.AddSingleton(); break; } @@ -96,6 +101,9 @@ private static void AddParameterProvider(IServiceCollection services, AppEnviron private static void AddAskAiUsecase(IServiceCollection services, AppEnvironment appEnvironment) { + var logger = GetLogger(services); + logger?.LogInformation("Configuring AskAi use case for environment {AppEnvironment}", appEnvironment); + _ = services.Configure(options => { var serviceProvider = services.BuildServiceProvider(); From 591ac71b5eba1e99ac9a149047b81da115c2f4a3 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 16:36:28 +0200 Subject: [PATCH 16/20] Fix AOT build --- .../Elastic.Documentation.Api.Infrastructure.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Elastic.Documentation.Api.Infrastructure.csproj b/src/api/Elastic.Documentation.Api.Infrastructure/Elastic.Documentation.Api.Infrastructure.csproj index 476c1ba00..009c0da0a 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Elastic.Documentation.Api.Infrastructure.csproj +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Elastic.Documentation.Api.Infrastructure.csproj @@ -4,6 +4,8 @@ net9.0 enable enable + true + $(InterceptorsPreviewNamespaces);Microsoft.AspNetCore.Http.Generated Elastic.Documentation.Api.Infrastructure Elastic.Documentation.Api.Infrastructure From 7fd8de6493fbd6001c90be65b7c571e904698530 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 18:26:46 +0200 Subject: [PATCH 17/20] Add services necessary for aws lambda As seen in https://docs.aws.amazon.com/lambda/latest/dg/csharp-package-asp.html#csharp-package-asp-deploy-minimal --- src/api/Elastic.Documentation.Api.Lambda/Program.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/api/Elastic.Documentation.Api.Lambda/Program.cs b/src/api/Elastic.Documentation.Api.Lambda/Program.cs index 8ea055886..8043d9dc3 100644 --- a/src/api/Elastic.Documentation.Api.Lambda/Program.cs +++ b/src/api/Elastic.Documentation.Api.Lambda/Program.cs @@ -9,6 +9,7 @@ var builder = WebApplication.CreateSlimBuilder(args); +builder.Services.AddControllers(); builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi, new SourceGeneratorLambdaJsonSerializer()); builder.Services.AddElasticDocsApiUsecases(Environment.GetEnvironmentVariable("APP_ENVIRONMENT")); @@ -17,6 +18,9 @@ var v1 = app.MapGroup("/v1"); v1.MapElasticDocsApiEndpoints(); +app.UseHttpsRedirection(); +app.MapControllers(); + app.Run(); [JsonSerializable(typeof(APIGatewayHttpApiV2ProxyRequest), GenerationMode = JsonSourceGenerationMode.Metadata)] From 2b671cdab2204f14cb9f38424cb1ca38d5128546 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 22:12:00 +0200 Subject: [PATCH 18/20] Revert "Add services necessary for aws lambda" This reverts commit 7fd8de6493fbd6001c90be65b7c571e904698530. --- src/api/Elastic.Documentation.Api.Lambda/Program.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/api/Elastic.Documentation.Api.Lambda/Program.cs b/src/api/Elastic.Documentation.Api.Lambda/Program.cs index 8043d9dc3..8ea055886 100644 --- a/src/api/Elastic.Documentation.Api.Lambda/Program.cs +++ b/src/api/Elastic.Documentation.Api.Lambda/Program.cs @@ -9,7 +9,6 @@ var builder = WebApplication.CreateSlimBuilder(args); -builder.Services.AddControllers(); builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi, new SourceGeneratorLambdaJsonSerializer()); builder.Services.AddElasticDocsApiUsecases(Environment.GetEnvironmentVariable("APP_ENVIRONMENT")); @@ -18,9 +17,6 @@ var v1 = app.MapGroup("/v1"); v1.MapElasticDocsApiEndpoints(); -app.UseHttpsRedirection(); -app.MapControllers(); - app.Run(); [JsonSerializable(typeof(APIGatewayHttpApiV2ProxyRequest), GenerationMode = JsonSourceGenerationMode.Metadata)] From 2717351642782aed37c49dfc855b7b33ece922b5 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 22:52:48 +0200 Subject: [PATCH 19/20] Add system prompt to business logic --- .../AskAi/AskAiUsecase.cs | 22 ++++++++++++++++++- .../Adapters/AskAi/LlmGatewayAskAiGateway.cs | 9 ++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs index c7c81cdbd..2c5f3e4ad 100644 --- a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs @@ -15,4 +15,24 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx) } } -public record AskAiRequest(string Message, string? ThreadId); +public record AskAiRequest(string Message, string? ThreadId) +{ + public static string SystemPrompt => + """ + Role: You are a specialized AI assistant designed to answer user questions exclusively from a set of provided documentation. Your primary purpose is to retrieve, synthesize, and present information directly from these documents. + + ## Core Directives: + + - Source of Truth: Your only source of information is the document content provided to you for each user query. You must not use any pre-trained knowledge or external information. + - Answering Style: Answer the user's question directly and comprehensively. As the user cannot ask follow-up questions, your response must be a complete, self-contained answer to their query. Do not start with phrases like "Based on the documents..."—simply provide the answer. + - Handling Unknowns: If the information required to answer the question is not present in the provided documents, you must explicitly state that the answer cannot be found. Do not attempt to guess, infer, or provide a general response. + - Helpful Fallback: If you cannot find a direct answer, you may suggest and link to a few related or similar topics that are present in the documentation. This provides value even when a direct answer is unavailable. + - Output Format: Your final response should be a single, coherent block of text. + + ## Negative Constraints: + + - Do not mention that you are a language model or AI. + - Do not provide answers based on your general knowledge. + - Do not ask the user for clarification. + """; +} 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 0c4460de1..79155b654 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs @@ -15,7 +15,7 @@ public class LlmGatewayAskAiGateway(HttpClient httpClient, GcpIdTokenProvider to { public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) { - var llmGatewayRequest = LlmGatewayRequest.CreateFromQuestion(askAiRequest.Message, askAiRequest.ThreadId); + var llmGatewayRequest = LlmGatewayRequest.CreateFromRequest(askAiRequest); var requestBody = JsonSerializer.Serialize(llmGatewayRequest, LlmGatewayContext.Default.LlmGatewayRequest); var request = new HttpRequestMessage(HttpMethod.Post, options.Value.FunctionUrl) { @@ -38,15 +38,16 @@ public record LlmGatewayRequest( string ThreadId ) { - public static LlmGatewayRequest CreateFromQuestion(string question, string? threadId = null) => + public static LlmGatewayRequest CreateFromRequest(AskAiRequest request) => new( UserContext: new UserContext("elastic-docs-v3@invalid"), PlatformContext: new PlatformContext("support_portal", "support_assistant", []), Input: [ - new ChatInput("user", question) + new ChatInput("system", AskAiRequest.SystemPrompt), + new ChatInput("user", request.Message) ], - ThreadId: threadId ?? "elastic-docs-" + Guid.NewGuid() + ThreadId: request.ThreadId ?? "elastic-docs-" + Guid.NewGuid() ); } From 45d895e3b994abd9390297810c48a0123b19093f Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 31 Jul 2025 22:53:17 +0200 Subject: [PATCH 20/20] Remove dead code --- .../SearchOrAskAi/useLlmGateway.ts | 58 ------------------- 1 file changed, 58 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts index 4ceb6c5ef..1e819efbe 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts @@ -216,62 +216,4 @@ function createLlmGatewayRequest( message, threadId, }) - - // return LlmGatewayRequestSchema.parse({ - // userContext: { - // userEmail: `elastic-docs-v3@invalid`, // Random email (will be optional in the future) - // }, - // platformContext: { - // origin: 'support_portal', - // useCase: 'support_assistant', - // metadata: {}, - // }, - // input: [ - // { - // role: 'user', - // message: ` - // # ROLE AND GOAL - // You are an expert AI assistant for the Elastic Stack (Elasticsearch, Kibana, Beats, Logstash, etc.). Your sole purpose is to answer user questions based *exclusively* on the provided context from the official Elastic Documentation. - // - // # CRITICAL INSTRUCTION: SINGLE-SHOT INTERACTION - // This is a single-turn interaction. The user cannot reply to your answer for clarification. Therefore, your response MUST be final, self-contained, and as comprehensive as possible based on the provided context. - // Also, keep the response as short as possible, but do not truncate the context. - // - // # RULES - // 1. **Facts** Always do RAG search to find the relevant Elastic documentation. - // 2. **Strictly Grounded Answers:** You MUST base your answer 100% on the information from the search results. Do not use any of your pre-trained knowledge or any information outside of this context. - // 3. **Handle Ambiguity Gracefully:** Since you cannot ask clarifying questions, if the question is broad or ambiguous (e.g., "how to improve performance"), structure your answer to cover the different interpretations supported by the context. - // * Acknowledge the ambiguity. For example: "Your question about 'performance' can cover several areas. Based on the documentation, here are the key aspects:" - // * Organize the answer with clear headings for each aspect (e.g., "Indexing Performance," "Query Performance"). - // * But if there is a similar or related topic in the docs you can mention it and link to it. - // 4. **Direct Answer First:** If the context directly and sufficiently answers a specific question, provide a clear, comprehensive, and well-structured answer. - // * Use Markdown for formatting (e.g., code blocks for configurations, bullet points for lists). - // * Use LaTeX for mathematical or scientific notations where appropriate (e.g., \`$E = mc^2$\`). - // * Make the answer as complete as possible, as this is the user's only response. - // * Keep the answer short and concise. We want to link users to the Elastic Documentation to find more information. - // 5. **Handling Incomplete Answers:** If the context contains relevant information but does not fully answer the question, you MUST follow this procedure: - // * Start by explicitly stating that you could not find a complete answer. - // * Then, summarize the related information you *did* find in the context, explaining how it might be helpful. - // 6. **Handling No Answer:** If the context is empty or completely irrelevant to the question, you MUST respond with the following, and nothing else: - // I was unable to find an answer to your question in the Elastic Documentation. - // - // For further assistance, you may want to: - // * Ask the community of experts at **discuss.elastic.co**. - // * If you have an Elastic subscription, contact our support engineers at **support.elastic.co**." - // 7. If you are 100% sure that something is not supported by Elastic, then say so. - // 8. **Tone:** Your tone should be helpful, professional, and confident. It is better to provide no answer (Rule #5) than an incorrect one. - // * Assume that the user is using Elastic for the first time. - // * Assume that the user is a beginner. - // * Assume that the user has a limited knowledge of Elastic - // * Explain unusual terminology, abbreviations, or acronyms. - // * Always try to cite relevant Elastic documentation. - // `, - // }, - // { - // role: 'user', - // message: question, - // }, - // ], - // threadId, - // }) }