From 0f42f2d3d2deb1cdb562bad354d1c68c3a57daa8 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Mon, 25 Aug 2025 12:02:14 -0400 Subject: [PATCH 01/32] Removed session ID from prompt input #371 --- .../react/src/components/chatbot/Chat.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index fa675ca85..a3bdd52d9 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -24,7 +24,6 @@ import { ButtonGroup, Checkbox, Grid, PromptInput, - TextContent, Icon, } from '@cloudscape-design/components'; import StatusIndicator from '@cloudscape-design/components/status-indicator'; @@ -153,7 +152,6 @@ export default function Chat ({ sessionId }) { const { session, setSession, - internalSessionId, setInternalSessionId, loadingSession, chatConfiguration, @@ -750,14 +748,7 @@ export default function Chat ({ sessionId }) { } /> - - - -
- Session ID: {internalSessionId} -
-
-
+ {enabledServers && enabledServers.length > 0 ? ( {enabledServers.length} MCP Servers - {openAiTools?.length || 0} tools From bbc965f4d9a6043caaa962119a0dba530aa2f613 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Mon, 25 Aug 2025 16:32:48 -0600 Subject: [PATCH 02/32] Updating MCP to allow for a test server button and fixed post create/update routing --- .../src/components/mcp/McpServerForm.tsx | 98 +++++++++++++++++-- 1 file changed, 92 insertions(+), 6 deletions(-) diff --git a/lib/user-interface/react/src/components/mcp/McpServerForm.tsx b/lib/user-interface/react/src/components/mcp/McpServerForm.tsx index 864e4b8bd..947859c04 100644 --- a/lib/user-interface/react/src/components/mcp/McpServerForm.tsx +++ b/lib/user-interface/react/src/components/mcp/McpServerForm.tsx @@ -24,6 +24,8 @@ import { Input, SpaceBetween, Toggle, + StatusIndicator, + Box, } from '@cloudscape-design/components'; import 'react'; import { useNavigate, useParams } from 'react-router-dom'; @@ -44,6 +46,7 @@ import { useUpdateMcpServerMutation } from '@/shared/reducers/mcp-server.reducer'; import { AttributeEditorSchema, EnvironmentVariables } from '@/shared/form/environment-variables'; +import { useMcp } from 'use-mcp/react'; export type McpServerFormProps = { isEdit?: boolean @@ -122,13 +125,60 @@ export function McpServerForm (props: McpServerFormProps) { const [sharePublic, setSharePublic] = useState(false); + // Test connection state + const [testConnectionUrl, setTestConnectionUrl] = useState(''); + const [isTestingConnection, setIsTestingConnection] = useState(false); + + // Test connection using useMcp + const { + state: connectionState, + tools, + } = useMcp({ + url: testConnectionUrl, + clientName: state.form.clientConfig?.name || 'Test Client', + clientConfig: state.form.clientConfig || {}, + customHeaders: state.form.customHeaders?.reduce((r,{key,value}) => (r[key] = value,r), {}), + autoReconnect: false, + autoRetry: false, + debug: false, + }); + + const testConnection = () => { + if (state.form.url && String(state.form.url).trim()) { + setIsTestingConnection(true); + setTestConnectionUrl(String(state.form.url).trim()); + + // Add a timeout to prevent infinite loading + setTimeout(() => { + if (isTestingConnection) { + setIsTestingConnection(false); + } + }, 30000); // 30 second timeout + } + }; + + // Reset test connection state when URL changes + useEffect(() => { + if (testConnectionUrl !== state.form.url) { + setTestConnectionUrl(''); + setIsTestingConnection(false); + } + }, [state.form.url, testConnectionUrl]); + + // Reset testing state when connection completes + useEffect(() => { + if (testConnectionUrl && (connectionState === 'ready' || connectionState === 'failed')) { + setIsTestingConnection(false); + } + }, [connectionState, testConnectionUrl]); + // create success notification useEffect(() => { if (isCreatingSuccess || isUpdatingSuccess) { const verb = isCreatingSuccess ? 'created' : 'updated'; const data = isCreatingSuccess ? createData : updateData; notificationService.generateNotification(`Successfully ${verb} MCP Connection: ${data.name}`, 'success'); - navigate('/mcp-connections'); + navigate(`/mcp-connections/${data.id}`); } }, [isCreatingSuccess, isUpdatingSuccess, notificationService, createData, updateData, navigate]); @@ -174,11 +224,47 @@ export function McpServerForm (props: McpServerFormProps) { placeholder='Enter MCP connection description' /> - touchFields(['url'])} onChange={({ detail }) => { - setFields({ 'url': detail.value }); - }} - disabled={disabled} - placeholder='Enter MCP server URL' /> + + touchFields(['url'])} onChange={({ detail }) => { + setFields({ 'url': detail.value }); + }} + disabled={disabled} + placeholder='Enter MCP server URL' /> + + + {testConnectionUrl && ( + + + {connectionState === 'ready' ? 'Connection successful' : + connectionState === 'failed' ? 'Connection failed' : + connectionState === 'discovering' ? 'Discovering server...' : + connectionState === 'authenticating' ? 'Authenticating...' : + connectionState === 'connecting' || connectionState === 'loading' ? 'Connecting...' : + 'Connection failed'} + + {connectionState === 'ready' && tools && ( + + Available tools: {tools.length} + + )} + {connectionState === 'failed' && ( + + Unable to connect to the MCP server. Please check the URL and try again. + + )} + + )} {isUserAdmin && From 7267aa94d298bc7163853b0e210e6f43bcbc9e42 Mon Sep 17 00:00:00 2001 From: Joseph Harold <121983012+jmharold@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:39:38 -0600 Subject: [PATCH 03/32] General UI Enhancements --- lib/docs/.vitepress/config.mts | 1 + lib/docs/config/model-management-ui.md | 150 +++++++++++++++++- lib/docs/user/model-library.md | 22 +++ .../react/src/components/chatbot/Chat.tsx | 8 +- .../components/chatbot/components/Message.tsx | 54 +++++-- .../chatbot/components/Sessions.tsx | 1 - lib/user-interface/react/src/index.css | 11 -- .../shared/model/chat.configurations.model.ts | 2 +- 8 files changed, 216 insertions(+), 33 deletions(-) create mode 100644 lib/docs/user/model-library.md diff --git a/lib/docs/.vitepress/config.mts b/lib/docs/.vitepress/config.mts index d52fe37c8..27516e2fc 100644 --- a/lib/docs/.vitepress/config.mts +++ b/lib/docs/.vitepress/config.mts @@ -75,6 +75,7 @@ const navLinks = [ items: [ { text: 'LISA Chat UI', link: '/user/chat' }, { text: 'Document Library Management', link: '/user/document-library' }, + { text: 'Model Library', link: '/user/model-library' }, { text: 'Breaking Changes', link: '/user/breaking-changes' }, { text: 'Change Log', link: 'https://github.com/awslabs/LISA/releases' }, ], diff --git a/lib/docs/config/model-management-ui.md b/lib/docs/config/model-management-ui.md index 464090415..be08cdbe6 100644 --- a/lib/docs/config/model-management-ui.md +++ b/lib/docs/config/model-management-ui.md @@ -1 +1,149 @@ -# TODO +# Model Management UI + +## Configuring Models + +LISA's Model Management UI allows Administrators to configure models for use with LISA. LISA supports third party models that are hosted externally to LISA that are compatible with LiteLLM. LISA also supports self-hosting models within Amazon ECS. LISA's Model Management wizard walks Administrators through configuration steps. + + +## Updating Models + +### Overview + +Through LISA's Model Management UI, Administrators can update the configuration of running models, to include their underlying infrastructure, without requiring a complete redeployment. Updates are processed through the `UpdateModel` state machine. + +### Update Considerations + +> [!WARNING] +> Updates to a LISA-hosted model's Container Configuration require a container restart in order to pick up the newly generated task definition. **This will result in a temporary outage**. Administrators must acknowledge this risk on the final step of the update wizard in order to submit the request. + +Models undergoing updating will not be selectable from the Chat UI. Users with existing sessions to the model being updated should expect to see errors returned when trying to prompt the model mid-update. + +Updated models automatically become available in the Chat UI once updates complete and status returns to `InService`. + +### Update Capabilities by Hosting Type + +| Update Category | Capability | Third-Party Models | LISA-Hosted Models | Service Impact | +|-----------------|------------|-------------------|-------------------|----------------| +| **Metadata Updates** | Model Description | ✅ | ✅ | No interruption | +| | Allowed Groups | ✅ | ✅ | No interruption | +| | Summarization Capabilities | ✅ | ✅ | No interruption | +| **Model Features** | Streaming | ✅ | ✅ | No interruption | +| | Tool Calls | ✅ | ✅ | No interruption | +| | Image Input | ✅ | ✅ | No interruption | +| | Summarization | ✅ | ✅ | No interruption | +| **Auto-Scaling Configuration** | Min Capacity | ❌ | ✅ | No interruption | +| | Max Capacity | ❌ | ✅ | No interruption | +| | Desired Capacity | ❌ | ✅ | No interruption | +| | Cooldown | ❌ | ✅ | No interruption | +| | Default Instance Warmup | ❌ | ✅ | No interruption | +| **Container Configuration** | Container Environment Variables | ❌ | ✅ | ECS restart required | +| | Shared Memory Size | ❌ | ✅ | ECS restart required | +| | Health Check Commands | ❌ | ✅ | ECS restart required | +| | Health Check Interval | ❌ | ✅ | ECS restart required | +| | Health Check Start Period | ❌ | ✅ | ECS restart required | +| | Health Check Timeout | ❌ | ✅ | ECS restart required | +| | Health Check Retries | ❌ | ✅ | ECS restart required | +| **Model Lifecycle** | Start/Stop Models | ❌ | ✅ | Service interruption | + +### Update Process Flow + +#### 1. Validation Phase +The system validates update requests against current model state: +- Ensures model is in `InService` or `Stopped` state +- Validates configuration conflicts +- Checks capacity constraints against existing auto-scaling groups +- Verifies container configuration compatibility + +#### 2. State Machine Orchestration +Updates are processed through a multi-step state machine: + +**Step 1 - Job Intake**: +- Processes update payload +- Determines required update types +- Sets model status to `Updating` +- Prepares infrastructure changes + +**Step 2 - ECS Updates** (if needed): +- Creates new task definition with updated container config +- Updates ECS service +- Monitors deployment progress +- Handles rollback on failures + +**Step 3 - Capacity Updates** (if needed): +- Updates auto-scaling group parameters +- Monitors instance health and availability +- Waits for capacity stabilization + +**Step 4 - Finalization**: +- Updates model metadata in database +- Restores model to `InService` status +- Registers model with inference endpoint if needed + +#### 3. Safety Mechanisms + +**State Validation**: +- Models cannot be updated during transitional states +- Updates requiring a container restart require explicit acknowledgment + +**Rollback Protection**: +- Failed deployments automatically scale down to prevent resource waste +- ECS updates include deployment monitoring with timeout protection +- Database state is preserved during failures + +**Resource Limits**: +- Polling timeouts prevent infinite waiting +- Capacity changes validate against AWS account limits +- Container updates respect ECS service constraints + +### Performing Model Updates + +#### Prerequisites +- Administrator access to LISA Model Management +- Target model is in `InService` or `Stopped` state +- Understanding of update impact (restart requirements) + +#### Update Procedure + +1. **Access Model Management UI** + - Navigate to `Administration` → `Model Management` + - Select the target model + - Select `Actions` → `Update` + +2. **Configuration Update** + - Use the multi-step update wizard to navigate through updatable configurations + - Review and Update the current model configurations + +3. **Submit Updates** + - Acknowledge the ECS restart warnings (if applicable) + - Review changes in final step + - Submit for processing + +4. **Monitor Progress** + - Model status will change to `Updating` + - Monitor through the Model Management UI (auto-refreshes every 30 seconds) + +### Troubleshooting Update Issues + +#### Common Update Failures + +**Validation Errors**: +- Model in wrong state for updates +- Configuration conflicts (e.g., min > max capacity) +- Invalid container configurations + +**Deployment Issues**: +- ECS deployment timeouts +- Health check failures +- Resource constraints + +**Capacity Problems**: +- Auto-scaling group update failures +- Instance launch issues +- Load balancer target group problems + +#### Resolution Steps + +1. **Check Model Status**: Ensure model is in an updatable state +2. **Review Configuration**: Validate all parameters against AWS limits +3. **Check State Machine Execution**: Check the recent executions of the `UpdateModel` state machine for any failures +4. **Monitor Logs**: Check CloudWatch logs for any detailed error information diff --git a/lib/docs/user/model-library.md b/lib/docs/user/model-library.md new file mode 100644 index 000000000..f70d47ff8 --- /dev/null +++ b/lib/docs/user/model-library.md @@ -0,0 +1,22 @@ +# Model Library + +## Overview + +LISA's Model Library allows non-Administrators to view details about the models they have access to. + +## Model Library User Guide + +### Activating / Deactivating the Model Library + +Administrators can activate / deactivate the Model library. +1. Select the `Administration` dropdown from the top navigation bar +2. Select `Configuration` +3. Under `Library Components` toggle `Show Model Library` + +### Accessing the Model Library + +If activated, the Model Library is accessible via LISA's top navigation bar nested under the `Libraries` dropdown. It provides a centralized location for users to: + +- View all model cards for models they have access to +- Browse model details +- Check model status diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index a3bdd52d9..f175a8c48 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -423,10 +423,10 @@ export default function Chat ({ sessionId }) { }, [sessionHealth]); useEffect(() => { - if (bottomRef) { - bottomRef?.current.scrollIntoView({ behavior: 'smooth' }); + if (bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: 'smooth' }); } - }, [session.history.length]); + }, [session.history.length, isStreaming, isRunning, generateResponse]); // Reset tool call counter when session changes useEffect(() => { @@ -723,7 +723,7 @@ export default function Chat ({ sessionId }) { )} {message?.type === 'human' && ( - - } - > -
- {renderContent(message.type, message.content)} -
-
+ + + } + > +
+ {renderContent(message.type, message.content)} +
+
+ + ['copy'].includes(detail.id) && + navigator.clipboard.writeText(getDisplayableMessage(message.content)) + } + ariaLabel='Chat actions' + dropdownExpandToViewport + items={[ + { + type: 'icon-button', + id: 'copy', + iconName: 'copy', + text: 'Copy Input', + popoverFeedback: ( + + Input copied + + ) + } + ]} + variant='icon' + /> +
)} {message?.type === MessageTypes.TOOL && ( diff --git a/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx b/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx index fdb763296..af2d05ecc 100644 --- a/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx @@ -112,7 +112,6 @@ export function Sessions ({ newSession }) { useEffect(() => { if (!isDeleteByIdLoading && isDeleteByIdSuccess) { notificationService.generateNotification('Successfully deleted session', 'success'); - navigate('ai-assistant'); newSession(); } else if (!isDeleteByIdLoading && isDeleteByIdError) { notificationService.generateNotification(`Error deleting session: ${deleteByIdError.data?.message ?? deleteByIdError.data}`, 'error'); diff --git a/lib/user-interface/react/src/index.css b/lib/user-interface/react/src/index.css index c11e19ff4..7c05d4ddc 100644 --- a/lib/user-interface/react/src/index.css +++ b/lib/user-interface/react/src/index.css @@ -1,13 +1,2 @@ -@tailwind base; @tailwind components; @tailwind utilities; - -div > pre > code { - word-wrap: break-word; - white-space: pre-line; -} - -ol { - list-style: decimal; - padding-left: 2rem; -} diff --git a/lib/user-interface/react/src/shared/model/chat.configurations.model.ts b/lib/user-interface/react/src/shared/model/chat.configurations.model.ts index 3eaffd89d..75e78279a 100644 --- a/lib/user-interface/react/src/shared/model/chat.configurations.model.ts +++ b/lib/user-interface/react/src/shared/model/chat.configurations.model.ts @@ -79,7 +79,7 @@ export const baseConfig: IChatConfiguration = { presence_penalty: null, temperature: null, seed: null, - stop: ['\nUser:', '\n User:', 'User:', 'User'], + stop: [], }, imageGenerationArgs: { size: '1024x1024', From c5ef436f3301221fd9c3c8cae12de5a63add8798 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Mon, 25 Aug 2025 16:45:07 -0600 Subject: [PATCH 04/32] Adding ability to define a default embedding model when creating/updating vector store --- lib/schema/ragSchema.ts | 1 + .../chatbot/components/RagOptions.tsx | 85 ++++++++++++++++--- .../configuration/RepositoryTableConfig.tsx | 7 ++ .../createRepository/RepositoryConfigForm.tsx | 33 ++++++- 4 files changed, 111 insertions(+), 15 deletions(-) diff --git a/lib/schema/ragSchema.ts b/lib/schema/ragSchema.ts index c4986ba59..72b68c761 100644 --- a/lib/schema/ragSchema.ts +++ b/lib/schema/ragSchema.ts @@ -97,6 +97,7 @@ export const RagRepositoryConfigSchema = z .regex(/^(?!-).*(? { - setSelectedEmbeddingOption(ragConfig?.embeddingModel?.modelId ?? undefined); - setSelectedRepositoryOption(ragConfig?.repositoryId ?? undefined); - }, [ragConfig]); + // Sync repository selection + if (ragConfig?.repositoryId !== selectedRepositoryOption) { + setSelectedRepositoryOption(ragConfig?.repositoryId ?? undefined); + } + + // Sync embedding model selection + if (ragConfig?.embeddingModel?.modelId !== selectedEmbeddingOption) { + setSelectedEmbeddingOption(ragConfig?.embeddingModel?.modelId ?? undefined); + } + + // Handle default embedding model when repository changes + if (ragConfig?.repositoryId && !ragConfig?.embeddingModel) { + const repository = repositories?.find((repo) => repo.repositoryId === ragConfig.repositoryId); + if (repository?.embeddingModelId) { + const model = allModels?.find((model) => model.modelId === repository.embeddingModelId); + if (model) { + setRagConfig((config) => ({ + ...config, + embeddingModel: model, + })); + } + } + } + + // Handle switching to repository with different default embedding model + if (ragConfig?.repositoryId) { + const repository = repositories?.find((repo) => repo.repositoryId === ragConfig.repositoryId); + if (repository?.embeddingModelId) { + const defaultModel = allModels?.find((model) => model.modelId === repository.embeddingModelId); + if (defaultModel && (!ragConfig?.embeddingModel || ragConfig.embeddingModel.modelId !== defaultModel.modelId)) { + // Auto-switch to the repository's default embedding model + setRagConfig((config) => ({ + ...config, + embeddingModel: defaultModel, + })); + } + } + } + }, [ragConfig?.repositoryId, ragConfig?.embeddingModel, repositories, allModels, selectedEmbeddingOption, selectedRepositoryOption, setRagConfig]); return ( @@ -75,12 +112,25 @@ export default function RagControls ({isRunning, setUseRag, setRagConfig, ragCon value={selectedRepositoryOption ?? ''} enteredTextLabel={(text) => `Use: "${text}"`} onChange={({ detail }) => { - setSelectedRepositoryOption(detail.value); - setRagConfig((config) => ({ - ...config, - repositoryId: detail.value, - repositoryType: detail.value, - })); + const newRepositoryId = detail.value; + setSelectedRepositoryOption(newRepositoryId); + + if (newRepositoryId) { + const repository = repositories?.find((repo) => repo.repositoryId === newRepositoryId); + setRagConfig((config) => ({ + ...config, + repositoryId: newRepositoryId, + repositoryType: repository?.type || 'unknown', + })); + } else { + // Clear repository selection + setRagConfig((config) => ({ + ...config, + repositoryId: undefined, + repositoryType: undefined, + embeddingModel: undefined, + })); + } }} options={repositories?.map((repository) => ({value: repository.repositoryId, label: repository?.repositoryName?.length ? repository?.repositoryName : repository.repositoryId})) || []} /> @@ -94,13 +144,22 @@ export default function RagControls ({isRunning, setUseRag, setRagConfig, ragCon value={selectedEmbeddingOption ?? ''} enteredTextLabel={(text) => `Use: "${text}"`} onChange={({ detail }) => { - setSelectedEmbeddingOption(detail.value); + const newModelId = detail.value; + setSelectedEmbeddingOption(newModelId); - const model = allModels.find((model) => model.modelId === detail.value); - if (model) { + if (newModelId) { + const model = allModels.find((model) => model.modelId === newModelId); + if (model) { + setRagConfig((config) => ({ + ...config, + embeddingModel: model, + })); + } + } else { + // Clear embedding model selection setRagConfig((config) => ({ ...config, - embeddingModel: model, + embeddingModel: undefined, })); } }} diff --git a/lib/user-interface/react/src/components/configuration/RepositoryTableConfig.tsx b/lib/user-interface/react/src/components/configuration/RepositoryTableConfig.tsx index 27e90a695..041664624 100644 --- a/lib/user-interface/react/src/components/configuration/RepositoryTableConfig.tsx +++ b/lib/user-interface/react/src/components/configuration/RepositoryTableConfig.tsx @@ -57,6 +57,13 @@ export function getTableDefinition ({ sortingField: 'type', visible: true, }, + { + id: 'embeddingModelId', + header: 'Default Embedding Model', + cell: (e) => e.embeddingModelId ?? '-', + sortingField: 'type', + visible: true, + }, { id: 'allowedGroups', header: 'Allowed Groups', diff --git a/lib/user-interface/react/src/components/configuration/createRepository/RepositoryConfigForm.tsx b/lib/user-interface/react/src/components/configuration/createRepository/RepositoryConfigForm.tsx index c38151b1b..5328ab2a3 100644 --- a/lib/user-interface/react/src/components/configuration/createRepository/RepositoryConfigForm.tsx +++ b/lib/user-interface/react/src/components/configuration/createRepository/RepositoryConfigForm.tsx @@ -14,12 +14,12 @@ limitations under the License. */ -import React, { ReactElement } from 'react'; +import React, { ReactElement, useMemo, useState } from 'react'; import { FormProps } from '../../../shared/form/form-props'; import FormField from '@cloudscape-design/components/form-field'; import Input from '@cloudscape-design/components/input'; import Select from '@cloudscape-design/components/select'; -import { SpaceBetween } from '@cloudscape-design/components'; +import { Autosuggest, SpaceBetween } from '@cloudscape-design/components'; import { OpenSearchNewClusterConfig, RagRepositoryConfig, @@ -33,6 +33,8 @@ import { ArrayInputField } from '../../../shared/form/array-input'; import { RdsConfigForm } from './RdsConfigForm'; import { OpenSearchConfigForm } from './OpenSearchConfigForm'; import { BedrockKnowledgeBaseConfigForm } from './BedrockKnowledgeBaseConfigForm'; +import { useGetAllModelsQuery } from '@/shared/reducers/model-management.reducer'; +import { ModelStatus, ModelType } from '@/shared/model/model-management.model'; export type RepositoryConfigProps = { isEdit: boolean @@ -41,6 +43,15 @@ export type RepositoryConfigProps = { export function RepositoryConfigForm (props: FormProps & RepositoryConfigProps): ReactElement { const { item, touchFields, setFields, formErrors, isEdit } = props; const shape = RagRepositoryConfigSchema.innerType().shape; + const { data: embeddingModels, isFetching: isFetchingEmbeddingModels } = useGetAllModelsQuery(undefined, {refetchOnMountOrArgChange: 5, + selectFromResult: (state) => ({ + isFetching: state.isFetching, + data: (state.data || []).filter((model) => model.modelType === ModelType.embedding && model.status === ModelStatus.InService), + })}); + const embeddingOptions = useMemo(() => { + return embeddingModels?.map((model) => ({value: model.modelId})) || []; + }, [embeddingModels]); + const [selectedEmbeddingOption, setSelectedEmbeddingOption] = useState(undefined); return ( & Re setFields({ 'repositoryName': detail.value }); }} placeholder='Postgres RAG' /> + + No embedding models available.} + filteringType='auto' + value={selectedEmbeddingOption ?? ''} + enteredTextLabel={(text) => `Use: "${text}"`} + onChange={({ detail }) => { + setSelectedEmbeddingOption(detail.value); + setFields({ 'embeddingModelId': detail.value }); + }} + options={embeddingOptions} + /> + From 8e694eaf75fdf03fe8bdb00fbcf48545dd7d5ebd Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Mon, 25 Aug 2025 16:52:07 -0600 Subject: [PATCH 05/32] Trim vector store IAM permissions --- .../code.end-to-end-test.nightly.yml | 2 - .../state_machine/delete-store.ts | 5 ++ lib/rag/vector-store/vector-store-creator.ts | 90 ++++++++++++++++--- test/cdk/stacks/nag.test.ts | 2 +- 4 files changed, 85 insertions(+), 14 deletions(-) diff --git a/.github/workflows/code.end-to-end-test.nightly.yml b/.github/workflows/code.end-to-end-test.nightly.yml index 213e0afdb..b55035000 100644 --- a/.github/workflows/code.end-to-end-test.nightly.yml +++ b/.github/workflows/code.end-to-end-test.nightly.yml @@ -36,8 +36,6 @@ jobs: cache: 'npm' - name: Install base dependencies run: npm ci - - name: Install Cypress deps - run: npm ci --prefix cypress - name: Run Cypress E2E Suite env: TEST_ACCOUNT_PASSWORD: ${{ secrets.TEST_ACCOUNT_PASSWORD }} diff --git a/lib/rag/vector-store/state_machine/delete-store.ts b/lib/rag/vector-store/state_machine/delete-store.ts index 38135d15c..cda13d22d 100644 --- a/lib/rag/vector-store/state_machine/delete-store.ts +++ b/lib/rag/vector-store/state_machine/delete-store.ts @@ -149,6 +149,11 @@ export class DeleteStoreStateMachine extends Construct { role: executionRole, }); + // Allow the Step Functions role to invoke the cleanup lambda + if (role) { + cleanupDocsFunc.grantInvoke(role); + } + const hasMoreDocs = new Choice(this, 'HasMoreDocs') .when(Condition.isNotNull('$.lastEvaluated'), new LambdaInvoke(this, 'CleanupRepositoryDocsRetry', { lambdaFunction: cleanupDocsFunc, diff --git a/lib/rag/vector-store/vector-store-creator.ts b/lib/rag/vector-store/vector-store-creator.ts index 93e62e1ac..089150d48 100644 --- a/lib/rag/vector-store/vector-store-creator.ts +++ b/lib/rag/vector-store/vector-store-creator.ts @@ -56,6 +56,17 @@ export class VectorStoreCreatorStack extends Construct { ], }); + cdkRole.addToPolicy(new iam.PolicyStatement({ + actions: [ + 's3:*', + 'ec2:*', + 'rds:*', + 'opensearch:*', + 'ssm:*', + ], + resources: ['*'] + }));// Additional CloudFormation permissions that might be needed + const lambdaExecutionRole = iam.Role.fromRoleArn( this, `${Roles.RAG_LAMBDA_EXECUTION_ROLE}-VectorStore`, @@ -65,25 +76,65 @@ export class VectorStoreCreatorStack extends Construct { ), ); - // Add permissions to create resources that will be in the dynamic stacks + // IAM: service-linked role creation for required services + cdkRole.addToPolicy(new iam.PolicyStatement({ + actions: ['iam:CreateServiceLinkedRole'], + resources: ['*'], + conditions: { + StringEquals: { + 'iam:AWSServiceName': ['opensearchservice.amazonaws.com', 'rds.amazonaws.com'] + } + } + })); + + // IAM: manage roles created within the dynamic stacks and allow passing to services cdkRole.addToPolicy(new iam.PolicyStatement({ actions: [ - 's3:*', - 'ec2:*', - 'rds:*', - 'opensearch:*', - 'ssm:*', - 'iam:*' + 'iam:CreateRole', + 'iam:DeleteRole', + 'iam:AttachRolePolicy', + 'iam:DetachRolePolicy', + 'iam:PutRolePolicy', + 'iam:DeleteRolePolicy', + 'iam:TagRole', + 'iam:UntagRole', + 'iam:GetRole', + 'iam:GetRolePolicy', + 'iam:ListRolePolicies', + 'iam:ListAttachedRolePolicies', + 'iam:ListRoleTags', + 'iam:UpdateAssumeRolePolicy', + 'iam:ListRoles' ], resources: ['*'], })); + // IAM: assume CDK bootstrap roles for deployment + cdkRole.addToPolicy(new iam.PolicyStatement({ + actions: ['iam:AssumeRole'], + resources: [ + `arn:${config.partition}:iam::${config.accountNumber}:role/cdk-*-deploy-role-${config.accountNumber}-${config.region}`, + `arn:${config.partition}:iam::${config.accountNumber}:role/cdk-hnb659fds-deploy-role-${config.accountNumber}-${config.region}` + ], + })); + + + cdkRole.addToPolicy(new iam.PolicyStatement({ + actions: ['iam:PassRole'], + resources: ['*'], + conditions: { + StringEquals: { + 'iam:PassedToService': [ + 'cloudformation.amazonaws.com', + 'lambda.amazonaws.com', + 'events.amazonaws.com' + ] + } + } + })); + const stateMachineRole = new iam.Role(this, createCdkId([config.deploymentName, config.deploymentStage, 'StateMachineRole']), { assumedBy: new iam.ServicePrincipal('states.amazonaws.com'), - managedPolicies: [ - iam.ManagedPolicy.fromAwsManagedPolicyName('AWSStepFunctionsFullAccess'), - iam.ManagedPolicy.fromAwsManagedPolicyName('AWSCloudFormationFullAccess'), - ], }); vectorStoreTable.grantReadWriteData(stateMachineRole); @@ -125,6 +176,23 @@ export class VectorStoreCreatorStack extends Construct { securityGroups: [props.vpc.securityGroups.lambdaSg], }); + // Allow the state machine to invoke the deployer Lambda + this.vectorStoreCreatorFn.grantInvoke(stateMachineRole); + + // Minimal policies for state machine role + stateMachineRole.addToPolicy(new iam.PolicyStatement({ + actions: ['lambda:InvokeFunction'], + resources: [this.vectorStoreCreatorFn.functionArn], + })); + stateMachineRole.addToPolicy(new iam.PolicyStatement({ + actions: ['cloudformation:DescribeStacks', 'cloudformation:DeleteStack'], + resources: ['*'], + })); + stateMachineRole.addToPolicy(new iam.PolicyStatement({ + actions: ['dynamodb:PutItem', 'dynamodb:UpdateItem', 'dynamodb:GetItem', 'dynamodb:DeleteItem'], + resources: [vectorStoreTable.tableArn], + })); + new CreateStoreStateMachine(this, 'CreateVectorStoreStateMachine', { config: props.config, executionRole: lambdaExecutionRole, diff --git a/test/cdk/stacks/nag.test.ts b/test/cdk/stacks/nag.test.ts index 3800ef201..95686c1c4 100644 --- a/test/cdk/stacks/nag.test.ts +++ b/test/cdk/stacks/nag.test.ts @@ -39,7 +39,7 @@ const nagResults: NagResult = { LisaIAM: [0,14,0,0], LisaModels: [1,77,0,64], LisaNetworking: [1,2,3,5], - LisaRAG: [3,53,0,50], + LisaRAG: [3,51,0,50], LisaServe: [1,24,0,32], LisaUI: [0,15,0,7], LisaMetrics: [1,11,0,12] From bf89b95bfa62ef399a4d13659bc1d17381defb20 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Tue, 26 Aug 2025 11:38:38 -0600 Subject: [PATCH 06/32] Time based session grouping --- lambda/session/lambda_functions.py | 14 +- .../chatbot/components/Sessions.tsx | 251 +++++++++++------- .../react/src/components/types.tsx | 1 + 3 files changed, 170 insertions(+), 96 deletions(-) diff --git a/lambda/session/lambda_functions.py b/lambda/session/lambda_functions.py index aad661d24..f580e1337 100644 --- a/lambda/session/lambda_functions.py +++ b/lambda/session/lambda_functions.py @@ -205,6 +205,9 @@ def _map_session(session: dict) -> Dict[str, Any]: "firstHumanMessage": _find_first_human_message(session), "startTime": session.get("startTime", None), "createTime": session.get("createTime", None), + "lastUpdated": session.get( + "lastUpdated", session.get("startTime", None) + ), # Fallback to startTime for backward compatibility } @@ -370,9 +373,9 @@ def rename_session(event: dict, context: dict) -> dict: table.update_item( Key={"sessionId": session_id, "userId": user_id}, - UpdateExpression="SET #name = :name", - ExpressionAttributeNames={"#name": "name"}, - ExpressionAttributeValues={":name": body.get("name")}, + UpdateExpression="SET #name = :name, #lastUpdated = :lastUpdated", + ExpressionAttributeNames={"#name": "name", "#lastUpdated": "lastUpdated"}, + ExpressionAttributeValues={":name": body.get("name"), ":lastUpdated": datetime.now().isoformat()}, ) return {"statusCode": 200, "body": json.dumps({"message": "Session name updated successfully"})} except ValueError as e: @@ -429,13 +432,15 @@ def put_session(event: dict, context: dict) -> dict: table.update_item( Key={"sessionId": session_id, "userId": user_id}, UpdateExpression="SET #history = :history, #name = :name, #configuration = :configuration, " - + "#startTime = :startTime, #createTime = if_not_exists(#createTime, :createTime)", + + "#startTime = :startTime, #createTime = if_not_exists(#createTime, :createTime), " + + "#lastUpdated = :lastUpdated", ExpressionAttributeNames={ "#history": "history", "#name": "name", "#configuration": "configuration", "#startTime": "startTime", "#createTime": "createTime", + "#lastUpdated": "lastUpdated", }, ExpressionAttributeValues={ ":history": messages, @@ -443,6 +448,7 @@ def put_session(event: dict, context: dict) -> dict: ":configuration": configuration, ":startTime": datetime.now().isoformat(), ":createTime": datetime.now().isoformat(), + ":lastUpdated": datetime.now().isoformat(), }, ReturnValues="UPDATED_NEW", ) diff --git a/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx b/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx index af2d05ecc..91239531a 100644 --- a/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx @@ -17,9 +17,9 @@ import SpaceBetween from '@cloudscape-design/components/space-between'; import Link from '@cloudscape-design/components/link'; import Header from '@cloudscape-design/components/header'; -import { ButtonDropdown, Grid, Input, Popover, Modal, FormField } from '@cloudscape-design/components'; +import { ButtonDropdown, Input, Popover, Modal, FormField, Grid } from '@cloudscape-design/components'; import Button from '@cloudscape-design/components/button'; -import { useCollection } from '@cloudscape-design/collection-hooks'; + import { useLazyGetConfigurationQuery } from '@/shared/reducers/configuration.reducer'; import { sessionApi, @@ -43,6 +43,8 @@ import JSZip from 'jszip'; import { downloadFile } from '@/shared/util/downloader'; import { setConfirmationModal } from '@/shared/reducers/modal.reducer'; + + export function Sessions ({ newSession }) { const dispatch = useAppDispatch(); const notificationService = useNotificationService(dispatch); @@ -77,7 +79,7 @@ export function Sessions ({ newSession }) { const [renameModalVisible, setRenameModalVisible] = useState(false); const [sessionToRename, setSessionToRename] = useState(null); const [newSessionName, setNewSessionName] = useState(''); - const { data: sessions } = useListSessionsQuery(null, { refetchOnMountOrArgChange: 5 }); + const { data: sessions, isLoading: isSessionsLoading } = useListSessionsQuery(null, { refetchOnMountOrArgChange: 5 }); // Filter sessions based on search query const filteredSessions = useMemo(() => { @@ -88,16 +90,47 @@ export function Sessions ({ newSession }) { .filter((session) => getSessionDisplay(session).toLowerCase().includes(searchQuery.toLowerCase())); }, [sessions, searchQuery]); - const { items } = useCollection(filteredSessions, { - sorting: { - defaultState: { - sortingColumn: { - sortingField: 'StartTime', - }, - isDescending: true, - }, - }, - }); + // Group and sort sessions by time periods + const groupedSessions = useMemo(() => { + const now = new Date(); + const groups = { + 'Last Day': [] as LisaChatSession[], + 'Last 7 Days': [] as LisaChatSession[], + 'Last Month': [] as LisaChatSession[], + 'Last 3 Months': [] as LisaChatSession[], + 'Older': [] as LisaChatSession[] + }; + + filteredSessions.forEach((session) => { + // Use lastUpdated if available, otherwise fallback to startTime for backward compatibility + const lastUpdated = session.lastUpdated || session.startTime; + const sessionDate = new Date(lastUpdated); + const diffInDays = (now.getTime() - sessionDate.getTime()) / (1000 * 60 * 60 * 24); + + if (diffInDays <= 1) { + groups['Last Day'].push(session); + } else if (diffInDays <= 7) { + groups['Last 7 Days'].push(session); + } else if (diffInDays <= 30) { + groups['Last Month'].push(session); + } else if (diffInDays <= 90) { + groups['Last 3 Months'].push(session); + } else { + groups['Older'].push(session); + } + }); + + // Sort sessions within each group by lastUpdated (most recent first) + Object.keys(groups).forEach((key) => { + groups[key as keyof typeof groups].sort((a, b) => { + const aTime = new Date(a.lastUpdated || a.startTime).getTime(); + const bTime = new Date(b.lastUpdated || b.startTime).getTime(); + return bTime - aTime; // Descending order (newest first) + }); + }); + + return groups; + }, [filteredSessions]); useEffect(() => { if (!auth.isLoading && auth.isAuthenticated) { @@ -236,85 +269,119 @@ export function Sessions ({ newSession }) { History -
- [{ colspan: 10 }, { colspan: 2 }])}> - {items.map((item) => ( - - - navigate(`ai-assistant/${item.sessionId}`)}> - - {getSessionDisplay(item, 40)} - - - - - { - if (e.detail.id === 'delete-session') { - dispatch( - setConfirmationModal({ - action: 'Delete', - resourceName: 'Session', - onConfirm: () => deleteById(item.sessionId), - description: `This will delete the Session: ${item.sessionId}.` - }) - ); - } else if (e.detail.id === 'download-session') { - getSessionById(item.sessionId).then((resp) => { - const sess: LisaChatSession = resp.data; - const file = new Blob([JSON.stringify(sess, null, 2)], { type: 'application/json' }); - downloadFile(URL.createObjectURL(file), `${sess.sessionId}.json`); - }); - } else if (e.detail.id === 'export-images') { - getSessionById(item.sessionId).then(async (resp) => { - const sess: LisaChatSession = resp.data; - const images = sess.history.filter((msg) => msg.type === 'ai' && messageContainsImage(msg.content)) - .flatMap((msg) => { - return msg.content.map((contentItem) => { - if (contentItem.type === 'image_url') { - return contentItem.image_url.url; - } - }); - }); - - if (images.length === 0) { - notificationService.generateNotification('No images found to export', 'info'); - } else { - const zip = new JSZip(); - const imagePromises = images.map(async (imageUrl, index) => { - try { - const blob = await fetchImage(imageUrl); - zip.file(`image_${index + 1}.png`, blob, { binary: true }); - } catch (error) { - console.error(`Error processing image ${index + 1}:`, error); - } - }); - - // Wait for all images to be processed - await Promise.all(imagePromises); - const content = await zip.generateAsync({ type: 'blob' }); - downloadFile(URL.createObjectURL(content), `${sess.sessionId}-images.zip`); - } - }); - } else if (e.detail.id === 'rename-session') { - handleRenameSession(item); - } - }} - /> - - - ))} - -
+ + {isSessionsLoading && ( + + + Loading sessions... + Please wait while we fetch your session history + + + )} + + {!isSessionsLoading && ( + + {(() => { + const timeGroups = Object.entries(groupedSessions); + + return timeGroups.map(([timeGroup, sessions]) => { + if (sessions.length === 0) return null; + + return ( + +
+ {timeGroup} ({sessions.length}) +
+ + {sessions.map((item) => ( + + + + navigate(`ai-assistant/${item.sessionId}`)}> + + {getSessionDisplay(item, 40)} + + + + + { + if (e.detail.id === 'delete-session') { + dispatch( + setConfirmationModal({ + action: 'Delete', + resourceName: 'Session', + onConfirm: () => deleteById(item.sessionId), + description: `This will delete the Session: ${item.sessionId}.` + }) + ); + } else if (e.detail.id === 'download-session') { + getSessionById(item.sessionId).then((resp) => { + const sess: LisaChatSession = resp.data; + const file = new Blob([JSON.stringify(sess, null, 2)], { type: 'application/json' }); + downloadFile(URL.createObjectURL(file), `${sess.sessionId}.json`); + }); + } else if (e.detail.id === 'export-images') { + getSessionById(item.sessionId).then(async (resp) => { + const sess: LisaChatSession = resp.data; + const images = sess.history.filter((msg) => msg.type === 'ai' && messageContainsImage(msg.content)) + .flatMap((msg) => { + if (Array.isArray(msg.content)) { + return msg.content.map((contentItem) => { + if (contentItem.type === 'image_url') { + return contentItem.image_url.url; + } + }); + } + return []; + }); + + if (images.length === 0) { + notificationService.generateNotification('No images found to export', 'info'); + } else { + const zip = new JSZip(); + const imagePromises = images.map(async (imageUrl, index) => { + try { + const blob = await fetchImage(imageUrl); + zip.file(`image_${index + 1}.png`, blob, { binary: true }); + } catch (error) { + console.error(`Error processing image ${index + 1}:`, error); + } + }); + + // Wait for all images to be processed + await Promise.all(imagePromises); + const content = await zip.generateAsync({ type: 'blob' }); + downloadFile(URL.createObjectURL(content), `${sess.sessionId}-images.zip`); + } + }); + } else if (e.detail.id === 'rename-session') { + handleRenameSession(item); + } + }} + /> + + + + ))} + +
+ ); + }); + })()} +
+ )} {/* Rename Session Modal */} Date: Tue, 26 Aug 2025 15:57:46 -0600 Subject: [PATCH 07/32] Updating Dependencies --- lib/core/layers/authorizer/requirements.txt | 2 +- lib/rag/ingestion/ingestion-image/requirements.txt | 8 ++++---- lib/rag/layer/requirements.txt | 2 +- lib/serve/rest-api/Dockerfile | 10 +++++----- lib/serve/rest-api/src/entrypoint.sh | 3 +++ lib/serve/rest-api/src/requirements.txt | 10 +++++----- lib/user-interface/react/package.json | 2 +- package-lock.json | 12 ++++++------ package.json | 6 +++--- requirements-dev.txt | 2 +- 10 files changed, 30 insertions(+), 27 deletions(-) diff --git a/lib/core/layers/authorizer/requirements.txt b/lib/core/layers/authorizer/requirements.txt index d58c98049..fb0ff6550 100644 --- a/lib/core/layers/authorizer/requirements.txt +++ b/lib/core/layers/authorizer/requirements.txt @@ -1,4 +1,4 @@ # urllib3<2 // Provided by Lambda -requests==2.32.3 +requests==2.32.4 cryptography==44.0.1 PyJWT==2.9.0 diff --git a/lib/rag/ingestion/ingestion-image/requirements.txt b/lib/rag/ingestion/ingestion-image/requirements.txt index 59b0c0369..97cd2f20d 100644 --- a/lib/rag/ingestion/ingestion-image/requirements.txt +++ b/lib/rag/ingestion/ingestion-image/requirements.txt @@ -3,10 +3,10 @@ # urllib3<2 // Provided by Lambda aioboto3==12.3.0 aiobotocore==2.11.2 -aiohttp==3.10.11 +aiohttp==3.12.14 boto3==1.34.34 click==8.1.7 -cryptography==43.0.3 +cryptography==44.0.1 fastapi_utils==0.7.0 fastapi==0.115.11 gunicorn==23.0.0 @@ -22,10 +22,10 @@ psycopg2-binary==2.9.9 pydantic==2.8.2 PyJWT==2.9.0 pynacl==1.5.0 -pypdf==4.3.1 +pypdf==6.0.0 lxml==5.1.0 python-docx==1.1.0 requests-aws4auth==1.2.3 -requests==2.32.3 +requests==2.32.4 text-generation==0.7.0 uvicorn==0.29.0 diff --git a/lib/rag/layer/requirements.txt b/lib/rag/layer/requirements.txt index 6128d3d98..36d98af3e 100644 --- a/lib/rag/layer/requirements.txt +++ b/lib/rag/layer/requirements.txt @@ -7,7 +7,7 @@ langchain-openai==0.2.11 opensearch-py==2.6.0 pgvector==0.2.5 psycopg2-binary==2.9.9 -pypdf==4.3.1 +pypdf==6.0.0 lxml==5.1.0 python-docx==1.1.0 requests-aws4auth==1.2.3 diff --git a/lib/serve/rest-api/Dockerfile b/lib/serve/rest-api/Dockerfile index 6954d104a..ceb768db6 100644 --- a/lib/serve/rest-api/Dockerfile +++ b/lib/serve/rest-api/Dockerfile @@ -18,11 +18,6 @@ WORKDIR /app COPY src/requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt -# Copy LiteLLM config directly to container, it will be updated at runtime -# with LISA-hosted models. This filename is expected in the entrypoint.sh file, so do not modify -# the filename unless you modify it in the entrypoint.sh file too. -RUN echo "$LITELLM_CONFIG" > litellm_config.yaml - # Copy the source code into the container COPY src/ ./src @@ -31,6 +26,11 @@ COPY TIKTOKEN_CACHE ./TIKTOKEN_CACHE # Generate the prisma binary RUN prisma generate +# Copy LiteLLM config directly to container, it will be updated at runtime +# with LISA-hosted models. This filename is expected in the entrypoint.sh file, so do not modify +# the filename unless you modify it in the entrypoint.sh file too. +RUN echo "$LITELLM_CONFIG" > litellm_config.yaml + # Make entrypoint.sh executable RUN chmod +x src/entrypoint.sh diff --git a/lib/serve/rest-api/src/entrypoint.sh b/lib/serve/rest-api/src/entrypoint.sh index 8e8c0bb6a..15fe25eda 100644 --- a/lib/serve/rest-api/src/entrypoint.sh +++ b/lib/serve/rest-api/src/entrypoint.sh @@ -5,6 +5,9 @@ set -e HOST="0.0.0.0" PORT="8080" +# Prisma client is generated during build +echo "Prisma client already generated during build" + # Update LiteLLM config that was already copied from config.yaml with runtime-deployed models. # Depends on SSM Parameter for registered models. echo "Configuring and starting LiteLLM" diff --git a/lib/serve/rest-api/src/requirements.txt b/lib/serve/rest-api/src/requirements.txt index c35af2e51..8230b4bab 100644 --- a/lib/serve/rest-api/src/requirements.txt +++ b/lib/serve/rest-api/src/requirements.txt @@ -1,9 +1,9 @@ -aioboto3==12.3.0 -aiobotocore==2.11.2 -aiohttp==3.10.11 -boto3==1.34.34 +aioboto3>=12.0.0,<15.0.0 +aiobotocore>=2.11.0,<3.0.0 +aiohttp==3.12.14 +boto3>=1.34.0,<1.37.0 click==8.1.7 -cryptography==43.0.3 +cryptography>=43.0.1,<44.0.0 fastapi==0.115.11 fastapi_utils==0.7.0 gunicorn==23.0.0 diff --git a/lib/user-interface/react/package.json b/lib/user-interface/react/package.json index 30c24e1d7..81e09ee73 100644 --- a/lib/user-interface/react/package.json +++ b/lib/user-interface/react/package.json @@ -40,7 +40,7 @@ "react-oidc-context": "^2.4.0", "react-redux": "^8.1.3", "react-router-dom": "^6.29.0", - "react-syntax-highlighter": "^15.6.1", + "react-syntax-highlighter": "^15.6.6", "react-textarea-autosize": "^8.5.7", "redux-persist": "^6.0.0", "regenerator-runtime": "^0.14.1", diff --git a/package-lock.json b/package-lock.json index 50f91b0f5..8f1954d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "cypress" ], "dependencies": { - "aws-cdk-lib": "^2.185.0", + "aws-cdk-lib": "^2.189.1", "aws-sdk": "^2.1692.0", "cdk-ecr-deployment": "^4.0.1", "cdk-nag": "^2.35.5", @@ -42,7 +42,7 @@ "@typescript-eslint/parser": "^6.21.0", "aws-cdk": "^2.184.0", "depcheck": "^1.4.7", - "esbuild": "^0.25.1", + "esbuild": "^0.25.2", "eslint": "^8.57.1", "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^4.6.2", @@ -128,7 +128,7 @@ "react-oidc-context": "^2.4.0", "react-redux": "^8.1.3", "react-router-dom": "^6.29.0", - "react-syntax-highlighter": "^15.6.1", + "react-syntax-highlighter": "^15.6.6", "react-textarea-autosize": "^8.5.7", "redux-persist": "^6.0.0", "regenerator-runtime": "^0.14.1", @@ -14572,9 +14572,9 @@ } }, "node_modules/react-syntax-highlighter": { - "version": "15.6.3", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.3.tgz", - "integrity": "sha512-HebdyA9r20hgmA0q8RyRJ4c/vB4E6KL2HeWb5MNjU3iJEiT2w9jfU2RJsmI6f3Cy3SGE5tm0AIkBzM/E7e9/lQ==", + "version": "15.6.6", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", + "integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", diff --git a/package.json b/package.json index a325f99e6..acc546f79 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@typescript-eslint/parser": "^6.21.0", "aws-cdk": "^2.184.0", "depcheck": "^1.4.7", - "esbuild": "^0.25.1", + "esbuild": "^0.25.2", "eslint": "^8.57.1", "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^4.6.2", @@ -74,10 +74,10 @@ "tsx": "^4.19.3", "typescript": "~5.1.6", "wait-on": "^8.0.3", - "zod2md": "^0.1.4" + "zod2md": "^0.1.9" }, "dependencies": { - "aws-cdk-lib": "^2.185.0", + "aws-cdk-lib": "^2.189.1", "aws-sdk": "^2.1692.0", "cdk-ecr-deployment": "^4.0.1", "cdk-nag": "^2.35.5", diff --git a/requirements-dev.txt b/requirements-dev.txt index 064706466..382c27126 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ uvicorn==0.22.0 boto3==1.35.95 tiktoken==0.9.0 python-docx==1.1.2 -pypdf==5.4.0 +pypdf==6.0.0 langchain-community==0.3.9 langchain-openai==0.2.11 langchain==0.3.9 From 1e0c448d4ffed57ee1e85df4bece22a4ecf68769 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Tue, 26 Aug 2025 22:14:52 -0600 Subject: [PATCH 08/32] Fixing document chunk processing --- lambda/repository/lambda_functions.py | 2 +- lambda/repository/rag_document_repo.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lambda/repository/lambda_functions.py b/lambda/repository/lambda_functions.py index ecf58132c..b77ec1d50 100644 --- a/lambda/repository/lambda_functions.py +++ b/lambda/repository/lambda_functions.py @@ -579,7 +579,7 @@ def list_docs(event: dict, context: dict) -> dict[str, list[dict] | str | None]: last_evaluated = query_string_params.get("lastEvaluated") docs, last_evaluated = doc_repo.list_all( - repository_id=repository_id, collection_id=collection_id, last_evaluated_key=last_evaluated + repository_id=repository_id, collection_id=collection_id, last_evaluated_key=last_evaluated, join_docs=True ) return {"documents": [doc.model_dump() for doc in docs], "lastEvaluated": last_evaluated} diff --git a/lambda/repository/rag_document_repo.py b/lambda/repository/rag_document_repo.py index bf3c34bd8..4e3236d37 100644 --- a/lambda/repository/rag_document_repo.py +++ b/lambda/repository/rag_document_repo.py @@ -122,6 +122,8 @@ def find_by_id(self, document_id: str, join_docs: bool = False) -> Optional[RagD docs[0]["subdocs"] = subdocs doc = RagDocument(**docs[0]) + if join_docs: + doc.chunks = len(subdocs) # Recalculate chunks after populating subdocs return doc except ClientError as e: logging.error(f"Error querying document: {e.response['Error']['Message']}") @@ -162,6 +164,7 @@ def find_by_name( for doc in docs: subdocs = self._get_subdoc_ids(self.find_subdocs_by_id(doc.get("document_id"))) doc["subdocs"] = subdocs + doc["chunks"] = len(subdocs) # Recalculate chunks after populating subdocs return docs def find_by_source( @@ -192,7 +195,7 @@ def find_by_source( while "LastEvaluatedKey" in response: response = self.doc_table.query( KeyConditionExpression=Key("pk").eq(pk), - FilterExpression=Key("document_name").eq(document_source), + FilterExpression=Key("source").eq(document_source), ExclusiveStartKey=response["LastEvaluatedKey"], ) @@ -203,6 +206,7 @@ def _yield_documents(self, items: list[dict], join_docs: bool) -> Generator[RagD document = RagDocument(**item) if join_docs: document.subdocs = self._get_subdoc_ids(self.find_subdocs_by_id(document.document_id)) + document.chunks = len(document.subdocs) # Recalculate chunks after populating subdocs yield document def list_all( @@ -251,6 +255,7 @@ def list_all( for doc in docs: subdocs = self._get_subdoc_ids(self.find_subdocs_by_id(doc.document_id)) doc.subdocs = subdocs + doc.chunks = len(subdocs) # Recalculate chunks after populating subdocs return docs, next_key From c2302b2902e7959b374a8aac24473a557ecfa028 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Wed, 27 Aug 2025 01:12:57 -0600 Subject: [PATCH 09/32] Revert "Fixing document chunk processing" This reverts commit 1e0c448d4ffed57ee1e85df4bece22a4ecf68769. --- lambda/repository/lambda_functions.py | 2 +- lambda/repository/rag_document_repo.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/lambda/repository/lambda_functions.py b/lambda/repository/lambda_functions.py index b77ec1d50..ecf58132c 100644 --- a/lambda/repository/lambda_functions.py +++ b/lambda/repository/lambda_functions.py @@ -579,7 +579,7 @@ def list_docs(event: dict, context: dict) -> dict[str, list[dict] | str | None]: last_evaluated = query_string_params.get("lastEvaluated") docs, last_evaluated = doc_repo.list_all( - repository_id=repository_id, collection_id=collection_id, last_evaluated_key=last_evaluated, join_docs=True + repository_id=repository_id, collection_id=collection_id, last_evaluated_key=last_evaluated ) return {"documents": [doc.model_dump() for doc in docs], "lastEvaluated": last_evaluated} diff --git a/lambda/repository/rag_document_repo.py b/lambda/repository/rag_document_repo.py index 4e3236d37..bf3c34bd8 100644 --- a/lambda/repository/rag_document_repo.py +++ b/lambda/repository/rag_document_repo.py @@ -122,8 +122,6 @@ def find_by_id(self, document_id: str, join_docs: bool = False) -> Optional[RagD docs[0]["subdocs"] = subdocs doc = RagDocument(**docs[0]) - if join_docs: - doc.chunks = len(subdocs) # Recalculate chunks after populating subdocs return doc except ClientError as e: logging.error(f"Error querying document: {e.response['Error']['Message']}") @@ -164,7 +162,6 @@ def find_by_name( for doc in docs: subdocs = self._get_subdoc_ids(self.find_subdocs_by_id(doc.get("document_id"))) doc["subdocs"] = subdocs - doc["chunks"] = len(subdocs) # Recalculate chunks after populating subdocs return docs def find_by_source( @@ -195,7 +192,7 @@ def find_by_source( while "LastEvaluatedKey" in response: response = self.doc_table.query( KeyConditionExpression=Key("pk").eq(pk), - FilterExpression=Key("source").eq(document_source), + FilterExpression=Key("document_name").eq(document_source), ExclusiveStartKey=response["LastEvaluatedKey"], ) @@ -206,7 +203,6 @@ def _yield_documents(self, items: list[dict], join_docs: bool) -> Generator[RagD document = RagDocument(**item) if join_docs: document.subdocs = self._get_subdoc_ids(self.find_subdocs_by_id(document.document_id)) - document.chunks = len(document.subdocs) # Recalculate chunks after populating subdocs yield document def list_all( @@ -255,7 +251,6 @@ def list_all( for doc in docs: subdocs = self._get_subdoc_ids(self.find_subdocs_by_id(doc.document_id)) doc.subdocs = subdocs - doc.chunks = len(subdocs) # Recalculate chunks after populating subdocs return docs, next_key From abc121837cd5fb63da4e1b2affedc142085b37b4 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Wed, 27 Aug 2025 01:14:55 -0600 Subject: [PATCH 10/32] Fixing document chunk processing --- lambda/models/domain_objects.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lambda/models/domain_objects.py b/lambda/models/domain_objects.py index 6d7ee2e58..3f47912c7 100644 --- a/lambda/models/domain_objects.py +++ b/lambda/models/domain_objects.py @@ -432,7 +432,9 @@ class RagDocument(BaseModel): def __init__(self, **data: Any) -> None: super().__init__(**data) self.pk = self.createPartitionKey(self.repository_id, self.collection_id) - self.chunks = len(self.subdocs) + # Only calculate chunks if not explicitly provided in data (for new documents) + if "chunks" not in data: + self.chunks = len(self.subdocs) @staticmethod def createPartitionKey(repository_id: str, collection_id: str) -> str: From b08182bd4d3576eed4fd1ff8084e3be99fb5ab1b Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Wed, 27 Aug 2025 19:48:13 +0000 Subject: [PATCH 11/32] Add container override config for batch ingestion --- lib/rag/ingestion/ingestion-job-construct.ts | 23 ++++++++++++-------- lib/schema/configSchema.ts | 3 ++- lib/util/paths.ts | 1 + package-lock.json | 2 +- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/lib/rag/ingestion/ingestion-job-construct.ts b/lib/rag/ingestion/ingestion-job-construct.ts index d02357116..194062d39 100644 --- a/lib/rag/ingestion/ingestion-job-construct.ts +++ b/lib/rag/ingestion/ingestion-job-construct.ts @@ -22,7 +22,7 @@ */ import { Duration, Size, StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; -import { BaseProps } from '../../schema'; +import { BaseProps, EcsSourceType } from '../../schema'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as batch from 'aws-cdk-lib/aws-batch'; @@ -37,6 +37,8 @@ import { getDefaultRuntime } from '../../api-base/utils'; import { StringParameter } from 'aws-cdk-lib/aws-ssm'; import * as fs from 'fs'; import * as crypto from 'crypto'; +import { BATCH_INGESTION_PATH, CodeFactory } from '../../util'; +import { ContainerImage } from 'aws-cdk-lib/aws-ecs'; // Props interface for the IngestionJobConstruct export type IngestionJobConstructProps = StackProps & BaseProps & { @@ -47,7 +49,7 @@ export type IngestionJobConstructProps = StackProps & BaseProps & { }; export class IngestionJobConstruct extends Construct { - constructor (scope: Construct, id: string, props: IngestionJobConstructProps) { + constructor(scope: Construct, id: string, props: IngestionJobConstructProps) { super(scope, id); const { config, vpc, lambdaRole, layers, baseEnvironment } = props; @@ -103,11 +105,10 @@ export class IngestionJobConstruct extends Construct { baseEnvironment['LISA_INGESTION_JOB_QUEUE_NAME'] = jobQueue.jobQueueName; // Set up build directory for Docker image - const ingestionImageRoot = path.join(__dirname, 'ingestion-image'); const buildDirName = 'build'; - const buildDir = path.join(ingestionImageRoot, buildDirName); + const buildDir = path.join(BATCH_INGESTION_PATH, buildDirName); - fs.mkdirSync(buildDir, {recursive: true}); + fs.mkdirSync(buildDir, { recursive: true }); const copyOptions = { recursive: true, @@ -136,12 +137,16 @@ export class IngestionJobConstruct extends Construct { }); } - // Build Docker image for batch jobs - const dockerImageAsset = new DockerImageAsset(this, 'IngestionJobImage', { - directory: ingestionImageRoot, + const imageConfig = config.batchIngestionConfig || { + baseImage: config.baseImage, + path: BATCH_INGESTION_PATH, + type: EcsSourceType.ASSET, buildArgs: { 'BUILD_DIR': buildDirName }, + }; + const image = CodeFactory.createImage(imageConfig, this, 'BatchIngestionContainer', { + 'BUILD_DIR': buildDirName }); // AWS Batch job definition specifying container configuration @@ -149,7 +154,7 @@ export class IngestionJobConstruct extends Construct { jobDefinitionName: `${config.deploymentName}-${config.deploymentStage}-ingestion-job-${hash}`, container: new batch.EcsFargateContainerDefinition(this, 'IngestionJobContainer', { environment: baseEnvironment, - image: ecs.ContainerImage.fromDockerImageAsset(dockerImageAsset), + image, memory: Size.mebibytes(4096), cpu: 2, command: ['-m', 'repository.pipeline_ingestion', 'Ref::ACTION', 'Ref::DOCUMENT_ID'], diff --git a/lib/schema/configSchema.ts b/lib/schema/configSchema.ts index a7b91d1b8..e5a5176aa 100644 --- a/lib/schema/configSchema.ts +++ b/lib/schema/configSchema.ts @@ -784,7 +784,8 @@ export const RawConfigObject = z.object({ region: z.string().describe('AWS region for deployment.'), partition: z.string().default('aws').describe('AWS partition for deployment.'), domain: z.string().default('amazonaws.com').describe('AWS domain for deployment'), - restApiConfig: FastApiContainerConfigSchema, + restApiConfig: FastApiContainerConfigSchema.describe('Image override for Rest API'), + batchIngestionConfig: ImageAssetSchema.optional().describe('Image override for Batch Ingestion'), vpcId: z.string().optional().describe('VPC ID for the application. (e.g. vpc-0123456789abcdef)'), subnets: z.array(z.object({ subnetId: z.string().startsWith('subnet-'), diff --git a/lib/util/paths.ts b/lib/util/paths.ts index 86c8c586d..0827ad38b 100644 --- a/lib/util/paths.ts +++ b/lib/util/paths.ts @@ -28,6 +28,7 @@ export const RAG_LAYER_PATH = path.join(ROOT_PATH, 'lib', 'rag', 'layer'); export const REST_API_PATH = path.join(ROOT_PATH, 'lib', 'serve', 'rest-api'); export const ECS_MODEL_PATH = path.join(ROOT_PATH, 'lib', 'serve', 'ecs-model'); +export const BATCH_INGESTION_PATH = path.join(ROOT_PATH, 'lib', 'rag', 'ingestion', 'ingestion-image'); export const WEBAPP_PATH = path.join(ROOT_PATH, 'lib', 'user-interface', 'react'); export const WEBAPP_DIST_PATH = path.join(WEBAPP_PATH, 'dist'); diff --git a/package-lock.json b/package-lock.json index 8f1954d06..975bc031e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "tsx": "^4.19.3", "typescript": "~5.1.6", "wait-on": "^8.0.3", - "zod2md": "^0.1.4" + "zod2md": "^0.1.9" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.0", From e24d2b7ce636e0014a28edc64dc72f51319a839c Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Thu, 28 Aug 2025 14:56:49 -0600 Subject: [PATCH 12/32] Add Document Library Pagination --- lambda/repository/lambda_functions.py | 48 +++- lambda/repository/rag_document_repo.py | 29 ++- .../DocumentLibraryComponent.tsx | 71 +++++- .../react/src/components/types.tsx | 16 +- .../react/src/shared/reducers/rag.reducer.ts | 38 +++- test/lambda/test_rag_document_repo_lambda.py | 16 +- test/lambda/test_repository_lambda.py | 206 +++++++++++++++++- 7 files changed, 386 insertions(+), 38 deletions(-) diff --git a/lambda/repository/lambda_functions.py b/lambda/repository/lambda_functions.py index ecf58132c..3e3976c94 100644 --- a/lambda/repository/lambda_functions.py +++ b/lambda/repository/lambda_functions.py @@ -16,7 +16,8 @@ import json import logging import os -from typing import Any, cast, Dict, List +import urllib.parse +from typing import Any, cast, Dict, List, Optional import boto3 import requests @@ -554,7 +555,7 @@ def presigned_url(event: dict, context: dict) -> dict: @api_wrapper -def list_docs(event: dict, context: dict) -> dict[str, list[dict] | str | None]: +def list_docs(event: dict, context: dict) -> dict[str, Any]: """List all documents for a given repository/collection. Args: @@ -564,8 +565,7 @@ def list_docs(event: dict, context: dict) -> dict[str, list[dict] | str | None]: context (dict): The Lambda context object Returns: - Tuple list[RagDocument], dict[lastEvaluatedKey]: A list of RagDocument objects representing all documents - in the specified collection and the last evaluated key for pagination + Dict containing documents, pagination info, and metadata Raises: KeyError: If collectionId is not provided in queryStringParameters @@ -576,12 +576,44 @@ def list_docs(event: dict, context: dict) -> dict[str, list[dict] | str | None]: query_string_params = event.get("queryStringParameters", {}) or {} collection_id = query_string_params.get("collectionId") - last_evaluated = query_string_params.get("lastEvaluated") + last_evaluated: Optional[dict[str, Optional[str]]] = None + + if "lastEvaluatedKeyPk" in query_string_params: + last_evaluated = { + "pk": ( + urllib.parse.unquote(query_string_params["lastEvaluatedKeyPk"]) + if "lastEvaluatedKeyPk" in query_string_params + else None + ), + "document_id": ( + urllib.parse.unquote(query_string_params["lastEvaluatedKeyDocumentId"]) + if "lastEvaluatedKeyDocumentId" in query_string_params + else None + ), + "repository_id": ( + urllib.parse.unquote(query_string_params["lastEvaluatedKeyRepositoryId"]) + if "lastEvaluatedKeyRepositoryId" in query_string_params + else None + ), + } + + page_size = int(query_string_params.get("pageSize", "10")) + + if page_size < 1: + page_size = 1 + elif page_size > 100: # Cap at 100 to prevent abuse + page_size = 100 - docs, last_evaluated = doc_repo.list_all( - repository_id=repository_id, collection_id=collection_id, last_evaluated_key=last_evaluated + docs, last_evaluated, total_documents = doc_repo.list_all( + repository_id=repository_id, collection_id=collection_id, last_evaluated_key=last_evaluated, limit=page_size ) - return {"documents": [doc.model_dump() for doc in docs], "lastEvaluated": last_evaluated} + return { + "documents": [doc.model_dump() for doc in docs], + "lastEvaluated": last_evaluated, + "totalDocuments": total_documents, + "hasNextPage": last_evaluated is not None, + "hasPreviousPage": "lastEvaluated" in query_string_params, + } @api_wrapper diff --git a/lambda/repository/rag_document_repo.py b/lambda/repository/rag_document_repo.py index bf3c34bd8..375f41e76 100644 --- a/lambda/repository/rag_document_repo.py +++ b/lambda/repository/rag_document_repo.py @@ -212,7 +212,7 @@ def list_all( last_evaluated_key: Optional[dict] = None, limit: int = 100, join_docs: bool = False, - ) -> tuple[list[RagDocument], Optional[dict]]: + ) -> tuple[list[RagDocument], Optional[dict], int]: """List all documents in a collection. Args: @@ -252,12 +252,37 @@ def list_all( subdocs = self._get_subdoc_ids(self.find_subdocs_by_id(doc.document_id)) doc.subdocs = subdocs - return docs, next_key + total_documents = self.count_documents(repository_id=repository_id, collection_id=collection_id) + + return docs, next_key, total_documents except ClientError as e: logging.error(f"Error listing documents: {e.response['Error']['Message']}") raise + def count_documents(self, repository_id: str, collection_id: Optional[str] = None) -> int: + """Count total documents in a repository/collection. + Args: + repository_id: Repository ID + collection_id?: Collection ID + Returns: + Total number of documents + """ + count = 0 + # Count all rag documents using repo id only + if not collection_id: + response = self.doc_table.query( + IndexName="repository_index", + KeyConditionExpression=Key("repository_id").eq(repository_id), + Select="COUNT", + ) + count = response.get("Count", 0) + else: + pk = RagDocument.createPartitionKey(repository_id, collection_id) + response = self.doc_table.query(KeyConditionExpression=Key("pk").eq(pk), Select="COUNT") + count = response.get("Count", 0) + return count + def find_subdocs_by_id(self, document_id: str) -> list[RagSubDocument]: """Query subdocuments using GSI. diff --git a/lib/user-interface/react/src/components/document-library/DocumentLibraryComponent.tsx b/lib/user-interface/react/src/components/document-library/DocumentLibraryComponent.tsx index efb2d1618..14c1fd823 100644 --- a/lib/user-interface/react/src/components/document-library/DocumentLibraryComponent.tsx +++ b/lib/user-interface/react/src/components/document-library/DocumentLibraryComponent.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react'; -import { ReactElement } from 'react'; +import { ReactElement, useState } from 'react'; import { Button, ButtonDropdownProps, @@ -61,15 +61,39 @@ function disabledDeleteReason (selectedItems: ReadonlyArray) { } export function DocumentLibraryComponent ({ repositoryId }: DocumentLibraryComponentProps): ReactElement { - const { data: allDocs, isFetching } = useListRagDocumentsQuery({ repositoryId }, { refetchOnMountOrArgChange: 5 }); const [deleteMutation, { isLoading: isDeleteLoading }] = useDeleteRagDocumentsMutation(); + const [currentPage, setCurrentPage] = useState(1); + const [lastEvaluatedKey, setLastEvaluatedKey] = useState<{ + pk: string; + document_id: string; + repository_id: string; + } | null>(null); + const [pageHistory, setPageHistory] = useState>([]); + const currentUser = useAppSelector(selectCurrentUsername); const isAdmin = useAppSelector(selectCurrentUserIsAdmin); const [preferences, setPreferences] = useLocalStorage('DocumentRagPreferences', DEFAULT_PREFERENCES); const dispatch = useAppDispatch(); - const { items, actions, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection( + const { data: paginatedDocs, isFetching } = useListRagDocumentsQuery( + { + repositoryId, + lastEvaluatedKey: lastEvaluatedKey || undefined, + pageSize: preferences.pageSize + }, + { refetchOnMountOrArgChange: 5 } + ); + + const allDocs = paginatedDocs?.documents || []; + const totalDocuments = paginatedDocs?.totalDocuments || 0; + const hasNextPage = paginatedDocs?.hasNextPage || false; + + const { items, actions, filteredItemsCount, collectionProps, filterProps } = useCollection( allDocs ?? [], { filtering: { empty: ( @@ -156,11 +180,7 @@ export function DocumentLibraryComponent ({ repositoryId }: DocumentLibraryCompo } header={
@@ -190,7 +210,38 @@ export function DocumentLibraryComponent ({ repositoryId }: DocumentLibraryCompo
} pagination={ - + { + if (hasNextPage && paginatedDocs?.lastEvaluated) { + // Add current key to history before moving to next page + setPageHistory([...pageHistory, lastEvaluatedKey]); + setLastEvaluatedKey(paginatedDocs.lastEvaluated); + // Update current page to reflect the navigation + setCurrentPage((prev) => prev + 1); + } + }} + onPreviousPageClick={() => { + if (pageHistory.length > 0) { + // Go back one page by popping from history + const previousKey = pageHistory[pageHistory.length - 1]; + setPageHistory(pageHistory.slice(0, -1)); + setLastEvaluatedKey(previousKey); + // Update current page to reflect the navigation + setCurrentPage((prev) => prev - 1); + } else { + // If no history, go to first page + setLastEvaluatedKey(null); + setCurrentPage(1); + } + }} + ariaLabels={{ + nextPageLabel: 'Next page', + previousPageLabel: 'Previous page', + pageLabel: (pageNumber) => `Page ${pageNumber} of ${Math.ceil(totalDocuments / (preferences.pageSize || 10))}`, + }} + /> } preferences={ ({ - query: (request) => ({ - url: `/repository/${request.repositoryId}/document`, - params: { collectionId: request.collectionId, lastEvaluatedKey: request.lastEvaluatedKey }, - }), - transformResponse: (response) => response.documents, + listRagDocuments: builder.query({ + query: (request) => { + const params: any = { collectionId: request.collectionId }; + + // Add pageSize parameter if provided + if (request.pageSize) { + params.pageSize = request.pageSize; + } + + // Construct lastEvaluatedKey parameters in the format the API expects + // Note: Using dot notation instead of brackets to avoid API Gateway limitations + if (request.lastEvaluatedKey) { + params['lastEvaluatedKeyPk'] = request.lastEvaluatedKey.pk; + params['lastEvaluatedKeyDocumentId'] = request.lastEvaluatedKey.document_id; + params['lastEvaluatedKeyRepositoryId'] = request.lastEvaluatedKey.repository_id; + } + + return { + url: `/repository/${request.repositoryId}/document`, + params, + }; + }, + transformResponse: (response) => response, providesTags: ['docs'], }), deleteRagDocuments: builder.mutation({ diff --git a/test/lambda/test_rag_document_repo_lambda.py b/test/lambda/test_rag_document_repo_lambda.py index bc6c200b6..2241cd2ac 100644 --- a/test/lambda/test_rag_document_repo_lambda.py +++ b/test/lambda/test_rag_document_repo_lambda.py @@ -472,10 +472,10 @@ def test_list_all_with_repository_id_only(sample_rag_document): repo.subdoc_table = mock_subdoc_table # Call the function - result, last_evaluated = repo.list_all("test-repo") + result, last_evaluated, total_documents = repo.list_all("test-repo") # Verify calls - mock_doc_table.query.assert_called_once() + assert mock_doc_table.query.call_count == 2 assert len(result) == 1 assert result[0].document_id == "test-doc-id" @@ -503,10 +503,10 @@ def test_list_all_with_collection_id(sample_rag_document): repo.subdoc_table = mock_subdoc_table # Call the function - result, last_evaluated = repo.list_all("test-repo", collection_id="test-collection") + result, last_evaluated, total_documents = repo.list_all("test-repo", collection_id="test-collection") # Verify calls - mock_doc_table.query.assert_called_once() + assert mock_doc_table.query.call_count == 2 assert len(result) == 1 assert result[0].document_id == "test-doc-id" @@ -537,10 +537,10 @@ def test_list_all_with_pagination(sample_rag_document): repo.subdoc_table = mock_subdoc_table # Call the function - result, last_evaluated = repo.list_all("test-repo") + result, last_evaluated, total_documents = repo.list_all("test-repo") # Verify calls - mock_doc_table.query.assert_called_once() + assert mock_doc_table.query.call_count == 2 assert len(result) == 1 assert result[0].document_id == "test-doc-id" assert last_evaluated == {"document_id": "next-key"} @@ -577,12 +577,12 @@ def test_list_all_with_join_docs(sample_rag_document): # (Assume this is done in the code or monkeypatch here if needed) # Call the function - result, last_evaluated = repo.list_all("test-repo", join_docs=True) + result, last_evaluated, total_documents = repo.list_all("test-repo", join_docs=True) # Verify result assert len(result) == 1 assert result[0].document_id == sample_rag_document.document_id - mock_doc_table.query.assert_called_once() + assert mock_doc_table.query.call_count == 2 def test_find_subdocs_by_id_success(sample_rag_sub_document): diff --git a/test/lambda/test_repository_lambda.py b/test/lambda/test_repository_lambda.py index a12abeb28..9a740f469 100644 --- a/test/lambda/test_repository_lambda.py +++ b/test/lambda/test_repository_lambda.py @@ -870,6 +870,9 @@ def mock_embed_query(query): with pytest.raises(ValidationError, match="Invalid query text"): embeddings.embed_query(None) + with pytest.raises(ValidationError, match="Invalid query text"): + embeddings.embed_query("") + def test_get_embeddings_pipeline_error(): """Test error handling in get_embeddings_pipeline function""" @@ -1586,6 +1589,7 @@ def test_real_download_document_function(): assert result["body"] == '"https://test-url"' +@mock_aws() def test_real_list_docs_function(): """Test the actual list_docs function""" from repository.lambda_functions import list_docs @@ -1599,7 +1603,12 @@ def test_real_list_docs_function(): mock_vs_repo.find_repository_by_id.return_value = {"allowedGroups": ["test-group"], "status": "active"} - mock_doc_repo.list_all.return_value = ([{"documentId": "test-doc", "name": "Test Document"}], None) + # Create a mock document object with model_dump method + mock_doc = MagicMock() + mock_doc.model_dump.return_value = {"documentId": "test-doc", "name": "Test Document"} + + # Mock list_all to return the correct tuple format: (docs, last_evaluated, total_documents) + mock_doc_repo.list_all.return_value = ([mock_doc], None, 1) event = { "requestContext": { @@ -1615,6 +1624,201 @@ def test_real_list_docs_function(): assert result["statusCode"] in [200, 500] +@mock_aws() +def test_list_docs_with_pagination(): + """Test list_docs function with pagination parameters""" + from repository.lambda_functions import list_docs + + with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( + "repository.lambda_functions.doc_repo" + ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups: + + # Setup mocks + mock_get_groups.return_value = ["test-group"] + mock_vs_repo.find_repository_by_id.return_value = {"allowedGroups": ["test-group"], "status": "active"} + + # Create mock documents + mock_doc1 = MagicMock() + mock_doc1.model_dump.return_value = {"documentId": "doc1", "name": "Document 1"} + mock_doc2 = MagicMock() + mock_doc2.model_dump.return_value = {"documentId": "doc2", "name": "Document 2"} + + # Mock list_all to return documents with pagination info + mock_doc_repo.list_all.return_value = ([mock_doc1, mock_doc2], {"pk": "next-page", "document_id": "doc2"}, 5) + + event = { + "requestContext": { + "authorizer": {"claims": {"username": "test-user"}, "groups": json.dumps(["test-group"])} + }, + "pathParameters": {"repositoryId": "test-repo"}, + "queryStringParameters": { + "collectionId": "test-collection", + "lastEvaluatedKeyPk": "current-page", + "lastEvaluatedKeyDocumentId": "doc1", + "lastEvaluatedKeyRepositoryId": "test-repo", + "pageSize": "2", + }, + } + + result = list_docs(event, SimpleNamespace()) + + # Verify the response structure and pagination info + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert len(body["documents"]) == 2 + assert body["totalDocuments"] == 5 + assert body["hasNextPage"] is True + assert body["hasPreviousPage"] is False # No 'lastEvaluated' key in queryStringParameters + assert body["lastEvaluated"] == {"pk": "next-page", "document_id": "doc2"} + + +@mock_aws() +def test_list_docs_with_previous_page(): + """Test list_docs function with previous page indicator""" + from repository.lambda_functions import list_docs + + with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( + "repository.lambda_functions.doc_repo" + ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups: + + # Setup mocks + mock_get_groups.return_value = ["test-group"] + mock_vs_repo.find_repository_by_id.return_value = {"allowedGroups": ["test-group"], "status": "active"} + + mock_doc = MagicMock() + mock_doc.model_dump.return_value = {"documentId": "test-doc", "name": "Test Document"} + + mock_doc_repo.list_all.return_value = ([mock_doc], None, 1) + + event = { + "requestContext": { + "authorizer": {"claims": {"username": "test-user"}, "groups": json.dumps(["test-group"])} + }, + "pathParameters": {"repositoryId": "test-repo"}, + "queryStringParameters": { + "collectionId": "test-collection", + "lastEvaluated": "true", # This indicates we're on a previous page + }, + } + + result = list_docs(event, SimpleNamespace()) + + # Verify the response handles previous page correctly + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert body["hasPreviousPage"] is True + + +@mock_aws() +def test_list_docs_with_custom_page_size(): + """Test list_docs function with custom page size""" + from repository.lambda_functions import list_docs + + with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( + "repository.lambda_functions.doc_repo" + ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups: + + # Setup mocks + mock_get_groups.return_value = ["test-group"] + mock_vs_repo.find_repository_by_id.return_value = {"allowedGroups": ["test-group"], "status": "active"} + + mock_doc = MagicMock() + mock_doc.model_dump.return_value = {"documentId": "test-doc", "name": "Test Document"} + + mock_doc_repo.list_all.return_value = ([mock_doc], None, 1) + + event = { + "requestContext": { + "authorizer": {"claims": {"username": "test-user"}, "groups": json.dumps(["test-group"])} + }, + "pathParameters": {"repositoryId": "test-repo"}, + "queryStringParameters": {"collectionId": "test-collection", "pageSize": "50"}, + } + + result = list_docs(event, SimpleNamespace()) + + # Verify the response + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert body["hasPreviousPage"] is False # No pagination parameters + + +@mock_aws() +def test_list_docs_with_edge_case_page_sizes(): + """Test list_docs function with edge case page sizes""" + from repository.lambda_functions import list_docs + + with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( + "repository.lambda_functions.doc_repo" + ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups: + + # Setup mocks + mock_get_groups.return_value = ["test-group"] + mock_vs_repo.find_repository_by_id.return_value = {"allowedGroups": ["test-group"], "status": "active"} + + mock_doc = MagicMock() + mock_doc.model_dump.return_value = {"documentId": "test-doc", "name": "Test Document"} + + mock_doc_repo.list_all.return_value = ([mock_doc], None, 1) + + # Test with page size 0 (should be clamped to 1) + event = { + "requestContext": { + "authorizer": {"claims": {"username": "test-user"}, "groups": json.dumps(["test-group"])} + }, + "pathParameters": {"repositoryId": "test-repo"}, + "queryStringParameters": {"collectionId": "test-collection", "pageSize": "0"}, + } + + result = list_docs(event, SimpleNamespace()) + assert result["statusCode"] == 200 + + # Test with page size > 100 (should be clamped to 100) + event["queryStringParameters"]["pageSize"] = "150" + result = list_docs(event, SimpleNamespace()) + assert result["statusCode"] == 200 + + +@mock_aws() +def test_list_docs_with_encoded_pagination_keys(): + """Test list_docs function with URL-encoded pagination keys""" + from repository.lambda_functions import list_docs + + with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( + "repository.lambda_functions.doc_repo" + ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups: + + # Setup mocks + mock_get_groups.return_value = ["test-group"] + mock_vs_repo.find_repository_by_id.return_value = {"allowedGroups": ["test-group"], "status": "active"} + + mock_doc = MagicMock() + mock_doc.model_dump.return_value = {"documentId": "test-doc", "name": "Test Document"} + + mock_doc_repo.list_all.return_value = ([mock_doc], None, 1) + + # Test with URL-encoded keys + event = { + "requestContext": { + "authorizer": {"claims": {"username": "test-user"}, "groups": json.dumps(["test-group"])} + }, + "pathParameters": {"repositoryId": "test-repo"}, + "queryStringParameters": { + "collectionId": "test-collection", + "lastEvaluatedKeyPk": "repo%3Atest-collection", + "lastEvaluatedKeyDocumentId": "doc%2Fwith%2Fslashes", + "lastEvaluatedKeyRepositoryId": "test%2Drepo", + }, + } + + result = list_docs(event, SimpleNamespace()) + + # Verify the response handles URL decoding correctly + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert body["hasPreviousPage"] is False # No 'lastEvaluated' key + + def test_real_create_function(): """Test the actual create function""" from repository.lambda_functions import create From 05a7fbeb22d127d69970c4e6be4cfdc1962c338a Mon Sep 17 00:00:00 2001 From: bedanley Date: Thu, 28 Aug 2025 15:02:32 -0600 Subject: [PATCH 13/32] Add Model Usage UI Components --- .../components/chatbot/components/Message.tsx | 30 ++- .../chatbot/components/UsageInfo.tsx | 61 ++++++ .../components/chatbot/hooks/chat.hooks.tsx | 44 +++- .../components/ModelComparisonComponents.tsx | 117 +++++++--- .../hooks/useModelComparison.hook.tsx | 199 ++++++++++++++---- .../react/src/components/types.tsx | 14 ++ .../react/src/pages/ModelComparison.tsx | 4 + 7 files changed, 392 insertions(+), 77 deletions(-) create mode 100644 lib/user-interface/react/src/components/chatbot/components/UsageInfo.tsx diff --git a/lib/user-interface/react/src/components/chatbot/components/Message.tsx b/lib/user-interface/react/src/components/chatbot/components/Message.tsx index db9972426..bb8959264 100644 --- a/lib/user-interface/react/src/components/chatbot/components/Message.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/Message.tsx @@ -30,11 +30,12 @@ import Avatar from '@cloudscape-design/chat-components/avatar'; import remarkBreaks from 'remark-breaks'; import { MessageContent } from '@langchain/core/messages'; import { base64ToBlob, fetchImage, getDisplayableMessage, messageContainsImage } from '@/components/utils'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { IChatConfiguration } from '@/shared/model/chat.configurations.model'; import { downloadFile } from '@/shared/util/downloader'; import Link from '@cloudscape-design/components/link'; import ImageViewer from '@/components/chatbot/components/ImageViewer'; +import UsageInfo from '@/components/chatbot/components/UsageInfo'; import { merge } from 'lodash'; type MessageProps = { @@ -48,9 +49,10 @@ type MessageProps = { handleSendGenerateRequest: () => void; setUserPrompt: (state: string) => void; chatConfiguration: IChatConfiguration; + showUsage?: boolean; }; -export default function Message ({ message, isRunning, showMetadata, isStreaming, markdownDisplay, setUserPrompt, setChatConfiguration, handleSendGenerateRequest, chatConfiguration, callingToolName }: MessageProps) { +export default function Message ({ message, isRunning, showMetadata, isStreaming, markdownDisplay, setUserPrompt, setChatConfiguration, handleSendGenerateRequest, chatConfiguration, callingToolName, showUsage = false }: MessageProps) { const currentUser = useAppSelector(selectCurrentUsername); const ragCitations = !isStreaming && message?.metadata?.ragDocuments ? message?.metadata.ragDocuments : undefined; const [resend, setResend] = useState(false); @@ -66,7 +68,7 @@ export default function Message ({ message, isRunning, showMetadata, isStreaming // eslint-disable-next-line react-hooks/exhaustive-deps }, [resend]); - const renderContent = (messageType: string, content: MessageContent, metadata?: LisaChatMessageMetadata) => { + const renderContent = (content: MessageContent, metadata?: LisaChatMessageMetadata) => { if (Array.isArray(content)) { return content.map((item, index) => { if (item.type === 'text') { @@ -227,6 +229,7 @@ export default function Message ({ message, isRunning, showMetadata, isStreaming tooltipText='Generative AI assistant' /> } + actions={showUsage ? : undefined} > Generating response @@ -246,6 +249,7 @@ export default function Message ({ message, isRunning, showMetadata, isStreaming tooltipText='Generative AI assistant' /> } + actions={showUsage ? : undefined} > 🔨Calling {callingToolName} tool 🔨 @@ -266,11 +270,19 @@ export default function Message ({ message, isRunning, showMetadata, isStreaming tooltipText='Generative AI assistant' /> } + actions={showUsage ? : undefined} > - {renderContent(message.type, message.content, message.metadata)} - {showMetadata && !isStreaming && - - } + {renderContent(message.content, message.metadata)} + {showMetadata && !isStreaming && + + + } {!isStreaming && !messageContainsImage(message.content) &&
@@ -313,13 +325,13 @@ export default function Message ({ message, isRunning, showMetadata, isStreaming } >
- {renderContent(message.type, message.content)} + {renderContent(message.content)}
['copy'].includes(detail.id) && - navigator.clipboard.writeText(getDisplayableMessage(message.content)) + navigator.clipboard.writeText(getDisplayableMessage(message.content)) } ariaLabel='Chat actions' dropdownExpandToViewport diff --git a/lib/user-interface/react/src/components/chatbot/components/UsageInfo.tsx b/lib/user-interface/react/src/components/chatbot/components/UsageInfo.tsx new file mode 100644 index 000000000..cc477dd9d --- /dev/null +++ b/lib/user-interface/react/src/components/chatbot/components/UsageInfo.tsx @@ -0,0 +1,61 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Badge, SpaceBetween } from '@cloudscape-design/components'; +import { UsageInfo as UsageInfoType } from '@/components/types'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCircleDown, faCircleUp } from '@fortawesome/free-regular-svg-icons'; +import { faStopwatch } from '@fortawesome/free-solid-svg-icons'; + + +type UsageInfoProps = { + /** Usage information object containing token counts and response time */ + usage?: UsageInfoType; + /** Whether to show completion tokens count (default: true) */ + showTokens?: boolean; + /** Whether to show response time (default: true) */ + showResponseTime?: boolean; +}; + +/** + * Component for displaying usage information badges including token counts and response time. + * Used in chat messages to show model performance metrics. + */ + +export default function UsageInfo ({ + usage, + showTokens = true, + showResponseTime = true, +}: UsageInfoProps) { + if (!usage) return null; + + return ( + + {showTokens && usage.completionTokens && ( + Tokens + {usage.promptTokens} + {usage.completionTokens} + + )} + {showResponseTime && usage.responseTime !== undefined && usage.responseTime !== null && ( + Response + {usage.responseTime.toFixed(2)}s + + )} + + ); +} diff --git a/lib/user-interface/react/src/components/chatbot/hooks/chat.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/chat.hooks.tsx index 024aec960..1bcd6c20e 100644 --- a/lib/user-interface/react/src/components/chatbot/hooks/chat.hooks.tsx +++ b/lib/user-interface/react/src/components/chatbot/hooks/chat.hooks.tsx @@ -77,6 +77,7 @@ export const useChatGeneration = ({ const generateResponse = async (params: GenerateLLMRequestParams) => { setIsRunning(true); stopRequested.current = false; + const startTime = performance.now(); // Start client timer try { // Handle image generation mode specifically if (isImageGenerationMode) { @@ -114,6 +115,9 @@ export const useChatGeneration = ({ type: 'image_url' })); + // Calculate response time + const responseTime = (performance.now() - startTime) / 1000; + // Save the response to the chat history setSession((prev) => ({ ...prev, @@ -124,6 +128,9 @@ export const useChatGeneration = ({ ...metadata, imageGeneration: true, imageGenerationParams: imageGenParams + }, + usage: { + responseTime: parseFloat(responseTime.toFixed(2)) } })], })); @@ -343,6 +350,27 @@ export const useChatGeneration = ({ }); } + // Calculate response time and update the final message with usage info + const responseTime = (performance.now() - startTime) / 1000; + setSession((prev) => { + const lastMessage = prev.history[prev.history.length - 1]; + if (lastMessage?.type === MessageTypes.AI) { + return { + ...prev, + history: [...prev.history.slice(0, -1), + new LisaChatMessage({ + ...lastMessage, + usage: { + ...lastMessage.usage, + responseTime: parseFloat(responseTime.toFixed(2)) + } + }) + ], + }; + } + return prev; + }); + await memory.saveContext({ input: params.input }, { output: resp.join('') }); setIsStreaming(false); } catch (exception) { @@ -355,10 +383,24 @@ export const useChatGeneration = ({ } else { const response = await llmClient.invoke(messages, { tools: modelSupportsTools ? openAiTools : undefined }); const content = response.content as string; + const usage = response.response_metadata.tokenUsage; + + // Calculate response time + const responseTime = (performance.now() - startTime) / 1000; + await memory.saveContext({ input: params.input }, { output: content }); setSession((prev) => ({ ...prev, - history: [...prev.history, new LisaChatMessage({ type: 'ai', content, metadata, toolCalls: [...(response.tool_calls ?? [])] })], + history: [...prev.history, new LisaChatMessage({ + type: 'ai', + content, + metadata, + toolCalls: [...(response.tool_calls ?? [])], + usage: { + ...usage, + responseTime: parseFloat(responseTime.toFixed(2)) + } + })], })); } } diff --git a/lib/user-interface/react/src/components/model-management/components/ModelComparisonComponents.tsx b/lib/user-interface/react/src/components/model-management/components/ModelComparisonComponents.tsx index 63ef4697b..bc9a0109e 100644 --- a/lib/user-interface/react/src/components/model-management/components/ModelComparisonComponents.tsx +++ b/lib/user-interface/react/src/components/model-management/components/ModelComparisonComponents.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import { ReactElement, memo } from 'react'; +import { ReactElement, memo, useCallback, useRef } from 'react'; import { Box, SpaceBetween, @@ -27,17 +27,17 @@ import { SelectProps, PromptInput } from '@cloudscape-design/components'; -import { IModel } from '../../../shared/model/model-management.model'; -import { ComparisonResponse, ModelSelection } from '../hooks/useModelComparison.hook'; +import { IModel } from '@/shared/model/model-management.model'; +import { ComparisonResponse, ModelSelection } from '@/components/model-management/hooks/useModelComparison.hook'; import { MODEL_COMPARISON_CONFIG, UI_CONFIG, PLACEHOLDERS, ARIA_LABELS -} from '../config/modelComparison.config'; -import { LisaChatMessage, MessageTypes } from '../../types'; -import Message from '../../chatbot/components/Message'; -import { IChatConfiguration } from '../../../shared/model/chat.configurations.model'; +} from '@/components/model-management/config/modelComparison.config'; +import { LisaChatMessage, MessageTypes } from '@/components/types'; +import Message from '@/components/chatbot/components/Message'; +import { IChatConfiguration } from '@/shared/model/chat.configurations.model'; import { downloadFile } from '@/shared/util/downloader'; type ModelSelectionSectionProps = { @@ -110,15 +110,68 @@ type PromptInputSectionProps = { prompt: string; onPromptChange: (value: string) => void; onCompare: () => void; + onStopComparison: () => void; canCompare: boolean; + shouldShowStopButton: boolean; }; export const PromptInputSection = memo(function PromptInputSection ({ prompt, onPromptChange, onCompare, - canCompare + onStopComparison, + canCompare, + shouldShowStopButton }: PromptInputSectionProps): ReactElement { + // Ref to track if we're processing a keyboard event + const isKeyboardEventRef = useRef(false); + + // Handle stop functionality similar to Chat.tsx + const handleStop = useCallback(() => { + onStopComparison(); + }, [onStopComparison]); + + // Custom action handler that only allows stop on button clicks + const handleAction = useCallback(() => { + // If this is a keyboard event, don't process it here (it's handled in handleKeyPress) + if (isKeyboardEventRef.current) { + return; + } + + if (shouldShowStopButton) { + // Only allow stop action on button clicks (not keyboard events) + handleStop(); + } else { + // Normal send functionality - allow both button clicks and Enter key + if (prompt.length > 0 && canCompare) { + onCompare(); + } + } + }, [shouldShowStopButton, handleStop, prompt.length, canCompare, onCompare]); + + // Handle Enter key press + const handleKeyPress = useCallback((event: any) => { + if (event.detail.key === 'Enter' && !event.detail.shiftKey) { + event.preventDefault(); + isKeyboardEventRef.current = true; + + // Handle the action directly for keyboard events + if (shouldShowStopButton) { + // Do nothing for stop button when Enter is pressed + } else { + // Normal send functionality for Enter key + if (prompt.length > 0 && canCompare) { + onCompare(); + } + } + + // Reset the flag after a short delay + setTimeout(() => { + isKeyboardEventRef.current = false; + }, 100); + } + }, [shouldShowStopButton, prompt.length, canCompare, onCompare]); + return ( Prompt @@ -126,17 +179,19 @@ export const PromptInputSection = memo(function PromptInputSection ({ value={prompt} onChange={({ detail }) => onPromptChange(detail.value)} placeholder={PLACEHOLDERS.PROMPT_INPUT} - actionButtonIconName='send' - actionButtonAriaLabel={ARIA_LABELS.SEND_PROMPT} - onAction={onCompare} - actionButtonDisabled={!canCompare} + actionButtonIconName={shouldShowStopButton ? 'status-negative' : 'send'} + actionButtonAriaLabel={shouldShowStopButton ? 'Stop comparison' : ARIA_LABELS.SEND_PROMPT} + onAction={handleAction} + onKeyDown={handleKeyPress} + maxRows={4} + minRows={2} + spellcheck={true} + disabled={!canCompare && !shouldShowStopButton} /> ); }); - - type ComparisonResultsProps = { prompt: string; responses: ComparisonResponse[]; @@ -172,13 +227,14 @@ export const ComparisonResults = memo(function ComparisonResults ({ content: response.loading ? '' : response.error ? `Error: ${response.error}` : response.response, metadata: { modelName: modelName - } + }, + usage: response.usage, }); }); // Dummy functions for Message component (not used in comparison context) - const handleSendGenerateRequest = () => {}; - const setUserPrompt = () => {}; + const handleSendGenerateRequest = () => { }; + const setUserPrompt = () => { }; const handleDownloadResults = (): void => { const results = responses.map((response) => { @@ -188,7 +244,8 @@ export const ComparisonResults = memo(function ComparisonResults ({ modelName: model?.modelName || response.modelId, response: response.response, error: response.error, - loading: response.loading + loading: response.loading, + usage: response.usage }; }); @@ -207,15 +264,13 @@ export const ComparisonResults = memo(function ComparisonResults ({ return ( - - + }>Comparison Results}> {/* Display user prompt */} @@ -224,13 +279,14 @@ export const ComparisonResults = memo(function ComparisonResults ({ message={userMessage} isRunning={false} callingToolName='' - showMetadata={false} + showMetadata={chatConfiguration.sessionConfiguration.showMetadata} isStreaming={false} markdownDisplay={markdownDisplay} setChatConfiguration={setChatConfiguration} handleSendGenerateRequest={handleSendGenerateRequest} setUserPrompt={setUserPrompt} chatConfiguration={chatConfiguration} + showUsage={true} /> )} @@ -246,13 +302,14 @@ export const ComparisonResults = memo(function ComparisonResults ({ message={aiMessages[index]} isRunning={response.loading} callingToolName='' - showMetadata={false} - isStreaming={false} + showMetadata={chatConfiguration.sessionConfiguration.showMetadata} + isStreaming={response.streaming} markdownDisplay={markdownDisplay} setChatConfiguration={setChatConfiguration} handleSendGenerateRequest={handleSendGenerateRequest} setUserPrompt={setUserPrompt} chatConfiguration={chatConfiguration} + showUsage={true} /> {response.error && ( diff --git a/lib/user-interface/react/src/components/model-management/hooks/useModelComparison.hook.tsx b/lib/user-interface/react/src/components/model-management/hooks/useModelComparison.hook.tsx index dc4ca8517..aac9b0bde 100644 --- a/lib/user-interface/react/src/components/model-management/hooks/useModelComparison.hook.tsx +++ b/lib/user-interface/react/src/components/model-management/hooks/useModelComparison.hook.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useRef } from 'react'; import { useAuth } from 'react-oidc-context'; import { ChatOpenAI } from '@langchain/openai'; import { SelectProps } from '@cloudscape-design/components'; @@ -29,7 +29,9 @@ export type ComparisonResponse = { modelId: string; response: string; loading: boolean; + streaming: boolean; error?: string; + usage?: any; }; export type ModelSelection = { @@ -49,6 +51,7 @@ export const useModelComparison = (models: IModel[], chatConfig: IChatConfigurat const [prompt, setPrompt] = useState(''); const [responses, setResponses] = useState([]); const [isComparing, setIsComparing] = useState(false); + const stopRequested = useRef(false); // Filter models to only show InService text generation models - memoized for performance const availableModels = useMemo(() => @@ -65,7 +68,7 @@ export const useModelComparison = (models: IModel[], chatConfig: IChatConfigurat [models] ); - const createOpenAiClient = useCallback((modelId: string) => { + const createOpenAiClient = useCallback((modelId: string, streaming: boolean = false) => { const model = models.find((m) => m.modelId === modelId); if (!model) return null; @@ -79,7 +82,7 @@ export const useModelComparison = (models: IModel[], chatConfig: IChatConfigurat configuration: { baseURL: `${RESTAPI_URI}/${RESTAPI_VERSION}/serve`, }, - streaming: sessionConfig.streaming || false, + streaming, maxTokens: sessionConfig.max_tokens || MODEL_COMPARISON_CONFIG.DEFAULT_MAX_TOKENS, temperature: modelArgs.temperature, topP: modelArgs.top_p, @@ -93,8 +96,15 @@ export const useModelComparison = (models: IModel[], chatConfig: IChatConfigurat return new ChatOpenAI(modelConfig); }, [models, auth, chatConfig]); - const generateModelResponse = async (modelId: string, userPrompt: string): Promise => { - const llmClient = createOpenAiClient(modelId); + const generateModelResponse = async ( + modelId: string, + userPrompt: string, + updateCallback: (modelId: string, update: Partial) => void + ): Promise => { + const startTime = performance.now(); // Start client timer + const useStreaming = chatConfig.sessionConfiguration.streaming || false; + const llmClient = createOpenAiClient(modelId, useStreaming); + if (!llmClient) { throw new Error(`Failed to create client for model ${modelId}`); } @@ -113,8 +123,97 @@ export const useModelComparison = (models: IModel[], chatConfig: IChatConfigurat ]; try { - const response = await llmClient.invoke(messages); - return response.content as string; + if (useStreaming) { + // Set streaming state + updateCallback(modelId, { streaming: true }); + + const stream = await llmClient.stream(messages); + const responseChunks: string[] = []; + + for await (const chunk of stream) { + // Check if stop was requested + if (stopRequested.current) { + const responseTime = (performance.now() - startTime) / 1000; + updateCallback(modelId, { + response: responseChunks.join(''), + loading: false, + streaming: false, + usage: { + responseTime: parseFloat(responseTime.toFixed(2)) + } + }); + return; + } + + const content = chunk.content as string; + responseChunks.push(content); + + // Update response with accumulated content + updateCallback(modelId, { + response: responseChunks.join(''), + streaming: true + }); + } + + // Calculate response time and finalize streaming + const responseTime = (performance.now() - startTime) / 1000; + updateCallback(modelId, { + response: responseChunks.join(''), + loading: false, + streaming: false, + usage: { + responseTime: parseFloat(responseTime.toFixed(2)) + } + }); + } else { + // Check if stop was requested before non-streaming call + if (stopRequested.current) { + const responseTime = (performance.now() - startTime) / 1000; + updateCallback(modelId, { + response: '', + loading: false, + streaming: false, + usage: { + responseTime: parseFloat(responseTime.toFixed(2)) + } + }); + return; + } + + // Non-streaming response + const response = await llmClient.invoke(messages); + + // Debug: Log the response structure to understand how to extract usage info + console.log('LangChain response structure:', { + response_metadata: response.response_metadata, + additional_kwargs: response.additional_kwargs, + full_response: response + }); + + // Calculate response time + const responseTime = (performance.now() - startTime) / 1000; + + // Extract usage information from response metadata (LangChain converts to camelCase) + const usage = response.response_metadata?.tokenUsage; + + console.log('Extracted values:', { + usage, + tokenUsage: response.response_metadata?.tokenUsage, + additional_kwargs: response.additional_kwargs + }); + + const finalUsage = { + ...usage, + responseTime: parseFloat(responseTime.toFixed(2)) + }; + + updateCallback(modelId, { + response: response.content as string, + loading: false, + streaming: false, + usage: finalUsage, + }); + } } catch (error) { console.error(`Error generating response for model ${modelId}:`, error); throw new Error(`Failed to generate response: ${error.message || 'Unknown error'}`); @@ -167,35 +266,48 @@ export const useModelComparison = (models: IModel[], chatConfig: IChatConfigurat } setIsComparing(true); + stopRequested.current = false; const initialResponses = selectedModels.map((model) => ({ modelId: model.value!, response: '', - loading: true + loading: true, + streaming: false })); setResponses(initialResponses); - // Make real API calls to all selected models - try { - const responsePromises = selectedModels.map(async (model) => { - try { - const response = await generateModelResponse(model.value!, prompt); - return { - modelId: model.value!, - response, - loading: false - }; - } catch (error) { - return { - modelId: model.value!, - response: '', - loading: false, - error: error.message || MESSAGES.FAILED_TO_GET_RESPONSE - }; - } - }); + // Update individual responses as they complete + const updateResponse = (modelId: string, update: Partial) => { + setResponses((prevResponses) => + prevResponses.map((response) => + response.modelId === modelId + ? { ...response, ...update } + : response + ) + ); + }; + + // Make API calls to all selected models and update each as it completes + const responsePromises = selectedModels.map(async (model) => { + const modelStartTime = performance.now(); + try { + await generateModelResponse(model.value!, prompt, updateResponse); + } catch (error) { + const responseTime = (performance.now() - modelStartTime) / 1000; + updateResponse(model.value!, { + response: '', + loading: false, + streaming: false, + error: error.message || MESSAGES.FAILED_TO_GET_RESPONSE, + usage: { + responseTime: parseFloat(responseTime.toFixed(2)) + } + }); + } + }); - const modelResponses = await Promise.all(responsePromises); - setResponses(modelResponses); + // Wait for all requests to complete before setting isComparing to false + try { + await Promise.all(responsePromises); } catch (error) { console.error('Error in model comparison:', error); notificationService.generateNotification( @@ -204,19 +316,26 @@ export const useModelComparison = (models: IModel[], chatConfig: IChatConfigurat undefined, error.message ?

{error.message}

: undefined ); - - const errorResponses = selectedModels.map((model) => ({ - modelId: model.value!, - response: '', - loading: false, - error: MESSAGES.FAILED_TO_GET_RESPONSE - })); - setResponses(errorResponses); } finally { setIsComparing(false); } }; + const stopComparison = useCallback(() => { + stopRequested.current = true; + setIsComparing(false); + notificationService.generateNotification('Model comparison stopped by user', 'info'); + + // Update any still-loading responses to stopped state + setResponses((prevResponses) => + prevResponses.map((response) => + response.loading || response.streaming + ? { ...response, loading: false, streaming: false } + : response + ) + ); + }, [notificationService]); + const resetComparison = useCallback(() => { setModelSelections([ { id: '1', selectedModel: null }, @@ -225,6 +344,7 @@ export const useModelComparison = (models: IModel[], chatConfig: IChatConfigurat setPrompt(''); setResponses([]); setIsComparing(false); + stopRequested.current = false; }, []); // Memoize expensive calculations @@ -238,6 +358,9 @@ export const useModelComparison = (models: IModel[], chatConfig: IChatConfigurat [selectedModelsCount, isComparing] ); + // Determine if we should show stop button - simplified like Chat.tsx + const shouldShowStopButton = isComparing; + return { // State modelSelections, @@ -246,6 +369,7 @@ export const useModelComparison = (models: IModel[], chatConfig: IChatConfigurat isComparing, availableModels, canCompare, + shouldShowStopButton, // Actions setPrompt, @@ -254,6 +378,7 @@ export const useModelComparison = (models: IModel[], chatConfig: IChatConfigurat updateModelSelection, getAvailableModelsForSelection, handleCompare, + stopComparison, resetComparison }; }; diff --git a/lib/user-interface/react/src/components/types.tsx b/lib/user-interface/react/src/components/types.tsx index 691f0a557..dcfa57eac 100644 --- a/lib/user-interface/react/src/components/types.tsx +++ b/lib/user-interface/react/src/components/types.tsx @@ -60,6 +60,17 @@ export type LisaChatMessageMetadata = { imageGeneration?: boolean; imageGenerationParams?: ImageGenerationParams; }; +/** + * Usage information from OpenAI API responses + */ +export type UsageInfo = { + completionTokens?: number; + responseTime?: number; + promptTokens?: number; + totalTokens?: number; + outputTokens?: number; +}; + /** * Interface for storing data for messages */ @@ -68,6 +79,7 @@ export type LisaChatMessageFields = { content: MessageContent; metadata?: LisaChatMessageMetadata; toolCalls?: any[]; + usage?: UsageInfo; } & BaseMessageFields; /** @@ -77,12 +89,14 @@ export class LisaChatMessage extends BaseMessage implements LisaChatMessageField type: MessageType; metadata?: LisaChatMessageMetadata; toolCalls?: any[]; + usage?: UsageInfo; constructor (fields: LisaChatMessageFields) { super(fields); this.type = fields.type; this.metadata = fields.metadata ?? {}; this.toolCalls = fields.toolCalls ?? []; + this.usage = fields.usage; } static lc_name () { diff --git a/lib/user-interface/react/src/pages/ModelComparison.tsx b/lib/user-interface/react/src/pages/ModelComparison.tsx index cb88840c9..590c50666 100644 --- a/lib/user-interface/react/src/pages/ModelComparison.tsx +++ b/lib/user-interface/react/src/pages/ModelComparison.tsx @@ -69,6 +69,7 @@ export default function ModelComparisonPage (): ReactElement { responses, availableModels, canCompare, + shouldShowStopButton, // Actions setPrompt, @@ -77,6 +78,7 @@ export default function ModelComparisonPage (): ReactElement { updateModelSelection, getAvailableModelsForSelection, handleCompare, + stopComparison, } = useModelComparison(models, modelConfiguration); const handleBack = () => { @@ -132,7 +134,9 @@ export default function ModelComparisonPage (): ReactElement { prompt={prompt} onPromptChange={setPrompt} onCompare={handleCompare} + onStopComparison={stopComparison} canCompare={canCompare} + shouldShowStopButton={shouldShowStopButton} /> {responses.length > 0 && ( From 4b9552858e4639725812646b220ca59419cb2b64 Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Fri, 29 Aug 2025 05:02:49 +0000 Subject: [PATCH 14/32] Organize ingestion code --- lambda/models/state_machine/create_model.py | 4 + lambda/models/state_machine/delete_model.py | 4 + lambda/repository/embeddings.py | 163 ++++++++++++++++++ lambda/repository/lambda_functions.py | 3 +- .../repository/pipeline_delete_documents.py | 3 +- .../repository/pipeline_ingest_documents.py | 5 +- lambda/requirements.txt | 1 + lambda/utilities/auth.py | 48 ++++++ lambda/utilities/common_functions.py | 20 --- lib/core/layers/index.ts | 14 +- lib/rag/ragConstruct.ts | 7 +- 11 files changed, 243 insertions(+), 29 deletions(-) create mode 100644 lambda/repository/embeddings.py create mode 100644 lambda/requirements.txt create mode 100644 lambda/utilities/auth.py diff --git a/lambda/models/state_machine/create_model.py b/lambda/models/state_machine/create_model.py index 3c97baa2c..cc745e792 100644 --- a/lambda/models/state_machine/create_model.py +++ b/lambda/models/state_machine/create_model.py @@ -22,6 +22,10 @@ from typing import Any, Dict import boto3 + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) from botocore.config import Config from models.clients.litellm_client import LiteLLMClient from models.domain_objects import CreateModelRequest, InferenceContainer, ModelStatus diff --git a/lambda/models/state_machine/delete_model.py b/lambda/models/state_machine/delete_model.py index e5f4ac9cc..cf3c3ec23 100644 --- a/lambda/models/state_machine/delete_model.py +++ b/lambda/models/state_machine/delete_model.py @@ -22,6 +22,10 @@ from uuid import uuid4 import boto3 + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) from models.clients.litellm_client import LiteLLMClient from utilities.common_functions import get_cert_path, get_rest_api_container_endpoint, retry_config diff --git a/lambda/repository/embeddings.py b/lambda/repository/embeddings.py new file mode 100644 index 000000000..6c95f266f --- /dev/null +++ b/lambda/repository/embeddings.py @@ -0,0 +1,163 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os +from typing import Any, List + +import boto3 +import requests +from utilities.common_functions import get_cert_path, retry_config +from utilities.validation import ValidationError + +logger = logging.getLogger(__name__) +ssm_client = boto3.client("ssm", region_name=os.environ["AWS_REGION"], config=retry_config) +secrets_client = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"], config=retry_config) +iam_client = boto3.client("iam", region_name=os.environ["AWS_REGION"], config=retry_config) + + +class PipelineEmbeddings: + """ + Handles document embeddings for pipeline processing using management credentials. + + This class provides methods to embed both single queries and batches of documents + using the LISA API with management-level authentication. + """ + + model_name: str + + def __init__(self, model_name: str) -> None: + try: + self.model_name = model_name + # Get the management key secret name from SSM Parameter Store + secret_name_param = ssm_client.get_parameter(Name=os.environ["MANAGEMENT_KEY_SECRET_NAME_PS"]) + secret_name = secret_name_param["Parameter"]["Value"] + + # Get the management token from Secrets Manager using the secret name + secret_response = secrets_client.get_secret_value(SecretId=secret_name) + self.token = secret_response["SecretString"] + + # Get the API endpoint from SSM + lisa_api_param_response = ssm_client.get_parameter(Name=os.environ["LISA_API_URL_PS_NAME"]) + self.base_url = f"{lisa_api_param_response['Parameter']['Value']}/{os.environ['REST_API_VERSION']}/serve" + + # Get certificate path for SSL verification + self.cert_path = get_cert_path(iam_client) + + logger.info("Successfully initialized pipeline embeddings") + except Exception: + logger.error("Failed to initialize pipeline embeddings", exc_info=True) + raise + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + """ + Generate embeddings for a list of documents. + + Args: + texts: List of text strings to embed + + Returns: + List of embedding vectors + + Raises: + ValidationError: If input texts are invalid + Exception: If embedding request fails + """ + if not texts: + raise ValidationError("No texts provided for embedding") + + logger.info(f"Embedding {len(texts)} documents") + try: + url = f"{self.base_url}/embeddings" + request_data = {"input": texts, "model": self.model_name} + + response = requests.post( + url, + json=request_data, + headers={"Authorization": self.token, "Content-Type": "application/json"}, + verify=self.cert_path, # Use proper SSL verification + timeout=300, # 5 minute timeout + ) + + if response.status_code != 200: + logger.error(f"Embedding request failed with status {response.status_code}") + logger.error(f"Response content: {response.text}") + raise Exception(f"Embedding request failed with status {response.status_code}") + + result = response.json() + logger.debug(f"API Response: {result}") # Log the full response for debugging + + # Handle different response formats + embeddings = [] + if isinstance(result, dict): + if "data" in result: + # OpenAI-style format + for item in result["data"]: + if isinstance(item, dict) and "embedding" in item: + embeddings.append(item["embedding"]) + else: + embeddings.append(item) # Assume the item itself is the embedding + else: + # Try to find embeddings in the response + for key in ["embeddings", "embedding", "vectors", "vector"]: + if key in result: + embeddings = result[key] + break + elif isinstance(result, list): + # Direct list format + embeddings = result + + if not embeddings: + logger.error(f"Could not find embeddings in response: {result}") + raise Exception("No embeddings found in API response") + + if len(embeddings) != len(texts): + logger.error(f"Mismatch between number of texts ({len(texts)}) and embeddings ({len(embeddings)})") + raise Exception("Number of embeddings does not match number of input texts") + + logger.info(f"Successfully embedded {len(texts)} documents") + return embeddings + + except requests.Timeout: + logger.error("Embedding request timed out") + raise Exception("Embedding request timed out after 5 minutes") + except requests.RequestException as e: + logger.error(f"Request failed: {str(e)}", exc_info=True) + raise + except Exception as e: + logger.error(f"Failed to get embeddings: {str(e)}", exc_info=True) + raise + + def embed_query(self, text: str) -> List[float]: + if not text or not isinstance(text, str): + raise ValidationError("Invalid query text") + + logger.info("Embedding single query text") + return self.embed_documents([text])[0] + + +def get_embeddings_pipeline(model_name: str) -> Any: + """ + Get embeddings for pipeline requests using management token. + + Args: + model_name: Name of the embedding model to use + + Raises: + ValidationError: If model name is invalid + Exception: If API request fails + """ + logger.info("Starting pipeline embeddings request") + + return PipelineEmbeddings(model_name=model_name) diff --git a/lambda/repository/lambda_functions.py b/lambda/repository/lambda_functions.py index ecf58132c..266f1dcd7 100644 --- a/lambda/repository/lambda_functions.py +++ b/lambda/repository/lambda_functions.py @@ -29,14 +29,13 @@ from repository.rag_document_repo import RagDocumentRepository from repository.vector_store_repo import VectorStoreRepository from utilities.bedrock_kb import is_bedrock_kb_repository, retrieve_documents +from utilities.auth import admin_only, is_admin from utilities.common_functions import ( - admin_only, api_wrapper, get_cert_path, get_groups, get_id_token, get_username, - is_admin, retry_config, user_has_group_access, ) diff --git a/lambda/repository/pipeline_delete_documents.py b/lambda/repository/pipeline_delete_documents.py index 452ea7835..437e3e0dd 100644 --- a/lambda/repository/pipeline_delete_documents.py +++ b/lambda/repository/pipeline_delete_documents.py @@ -24,7 +24,8 @@ from utilities.bedrock_kb import delete_document_from_kb, is_bedrock_kb_repository from utilities.common_functions import retry_config -from .lambda_functions import DocumentIngestionService, RagDocumentRepository +from repository.rag_document_repo import RagDocumentRepository +from repository.ingestion_service import DocumentIngestionService ingestion_service = DocumentIngestionService() ingestion_job_repository = IngestionJobRepository() diff --git a/lambda/repository/pipeline_ingest_documents.py b/lambda/repository/pipeline_ingest_documents.py index d53a7b0b6..20f39904f 100644 --- a/lambda/repository/pipeline_ingest_documents.py +++ b/lambda/repository/pipeline_ingest_documents.py @@ -22,14 +22,15 @@ import boto3 from models.domain_objects import FixedChunkingStrategy, IngestionJob, IngestionStatus, IngestionType, RagDocument from repository.ingestion_job_repo import IngestionJobRepository -from repository.lambda_functions import RagDocumentRepository +from repository.rag_document_repo import RagDocumentRepository from repository.vector_store_repo import VectorStoreRepository from utilities.bedrock_kb import ingest_document_to_kb, is_bedrock_kb_repository from utilities.common_functions import get_username, retry_config from utilities.file_processing import generate_chunks from utilities.vector_store import get_vector_store_client -from .lambda_functions import DocumentIngestionService, get_embeddings_pipeline +from repository.ingestion_service import DocumentIngestionService +from repository.embeddings import get_embeddings_pipeline dynamodb = boto3.resource("dynamodb", region_name=os.environ["AWS_REGION"], config=retry_config) ingestion_job_table = dynamodb.Table(os.environ["LISA_INGESTION_JOB_TABLE_NAME"]) diff --git a/lambda/requirements.txt b/lambda/requirements.txt new file mode 100644 index 000000000..a9cb84869 --- /dev/null +++ b/lambda/requirements.txt @@ -0,0 +1 @@ +# All required depenedencies are pulled in via layer diff --git a/lambda/utilities/auth.py b/lambda/utilities/auth.py new file mode 100644 index 000000000..789944280 --- /dev/null +++ b/lambda/utilities/auth.py @@ -0,0 +1,48 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from functools import wraps +from typing import Any, Callable, Dict + +from utilities.exceptions import HTTPException + +logger = logging.getLogger(__name__) + + +def get_username(event: dict) -> str: + """Get the username from the event.""" + username: str = event.get("requestContext", {}).get("authorizer", {}).get("username", "system") + return username + + +def is_admin(event: dict) -> bool: + """Get admin status from event.""" + admin_group = os.environ.get("ADMIN_GROUP", "") + groups = get_groups(event) + logger.info(f"User groups: {groups} and admin: {admin_group}") + return admin_group in groups + + +def admin_only(func: Callable) -> Callable: + """Annotation to wrap is_admin""" + + @wraps(func) + def wrapper(event: Dict[str, Any], context: Dict[str, Any], *args: Any, **kwargs: Any) -> Any: + if not is_admin(event): + raise HTTPException(status_code=403, message="User does not have permission to access this repository") + return func(event, context, *args, **kwargs) + + return wrapper + diff --git a/lambda/utilities/common_functions.py b/lambda/utilities/common_functions.py index 43746bc13..70ff5199e 100644 --- a/lambda/utilities/common_functions.py +++ b/lambda/utilities/common_functions.py @@ -365,26 +365,6 @@ def get_username(event: dict) -> str: return username -def is_admin(event: dict) -> bool: - """Get admin status from event.""" - admin_group = os.environ.get("ADMIN_GROUP", "") - groups = get_groups(event) - logger.info(f"User groups: {groups} and admin: {admin_group}") - return admin_group in groups - - -def admin_only(func: Callable) -> Callable: - """Annotation to wrap is_admin""" - - @wraps(func) - def wrapper(event: Dict[str, Any], context: Dict[str, Any], *args: Any, **kwargs: Any) -> Any: - if not is_admin(event): - raise HTTPException(status_code=403, message="User does not have permission to access this repository") - return func(event, context, *args, **kwargs) - - return wrapper - - def get_session_id(event: dict) -> str: """Get the session ID from the event.""" session_id: str = event.get("pathParameters", {}).get("sessionId") diff --git a/lib/core/layers/index.ts b/lib/core/layers/index.ts index 08bcab457..ee9bfc7e7 100644 --- a/lib/core/layers/index.ts +++ b/lib/core/layers/index.ts @@ -38,6 +38,7 @@ type LayerProps = { slimDeployment?: boolean; removePackages?: string[]; assetPath?: string; + afterBundle?: (inputDir: string, outputDir: string) => string[]; } & BaseProps; /** @@ -55,7 +56,7 @@ export class Layer extends Construct { constructor (scope: Construct, id: string, props: LayerProps) { super(scope, id); - const { assetPath, config, path: layerPath, description, architecture } = props; + const { assetPath, config, path: layerPath, description, architecture, afterBundle } = props; if (!fs.existsSync(`${layerPath}/requirements.txt`)) { throw new Error(`requirements.txt not found in ${layerPath}`); @@ -80,12 +81,19 @@ export class Layer extends Construct { removalPolicy: config.removalPolicy, bundling: { platform: architecture.dockerPlatform, - commandHooks: packagesExists ? { + commandHooks: (packagesExists || afterBundle) ? { beforeBundling (inputDir: string, outputDir: string): string[] { return [`touch ${outputDir}/requirements.txt`]; }, afterBundling (inputDir: string, outputDir: string): string[] { - return [`cp -r ${inputDir}/packages/* ${outputDir}/python/`]; + const commands = []; + if (packagesExists) { + commands.push(`cp -r ${inputDir}/packages/* ${outputDir}/python/`); + } + if (afterBundle) { + commands.push(...afterBundle(inputDir, outputDir)); + } + return commands; }, } : undefined }, diff --git a/lib/rag/ragConstruct.ts b/lib/rag/ragConstruct.ts index e5c05ef26..2fb478cc5 100644 --- a/lib/rag/ragConstruct.ts +++ b/lib/rag/ragConstruct.ts @@ -220,11 +220,16 @@ export class LisaRagConstruct extends Construct { const ragLambdaLayer = new Layer(scope, 'RagLayer', { config: config, path: RAG_LAYER_PATH, - description: 'Lambad dependencies for RAG API', + description: 'Lambda dependencies for RAG API', architecture: ARCHITECTURE, autoUpgrade: true, assetPath: config.lambdaLayerAssets?.ragLayerPath, + afterBundle: (inputDir: string, outputDir: string) => [ + `mkdir -p ${outputDir}/python/TIKTOKEN_CACHE`, + `cp -r ${inputDir}/TIKTOKEN_CACHE/* ${outputDir}/python/TIKTOKEN_CACHE/` + ], }); + new StringParameter(scope, createCdkId([config.deploymentName, config.deploymentStage, 'RagLayer']), { parameterName: `${config.deploymentPrefix}/layerVersion/rag`, stringValue: ragLambdaLayer.layer.layerVersionArn From 930e4ad1d17e64af1aa98553b697b4e8b4b9f22e Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Sat, 30 Aug 2025 19:57:55 +0000 Subject: [PATCH 15/32] Fix imports --- lambda/models/state_machine/create_model.py | 4 ---- lambda/models/state_machine/delete_model.py | 4 ---- lambda/repository/lambda_functions.py | 2 +- lambda/repository/pipeline_delete_documents.py | 5 ++--- lambda/repository/pipeline_ingest_documents.py | 5 ++--- lambda/requirements.txt | 2 +- lambda/utilities/auth.py | 4 ++-- lambda/utilities/common_functions.py | 3 +-- lib/rag/ingestion/ingestion-job-construct.ts | 4 +--- 9 files changed, 10 insertions(+), 23 deletions(-) diff --git a/lambda/models/state_machine/create_model.py b/lambda/models/state_machine/create_model.py index cc745e792..3c97baa2c 100644 --- a/lambda/models/state_machine/create_model.py +++ b/lambda/models/state_machine/create_model.py @@ -22,10 +22,6 @@ from typing import Any, Dict import boto3 - -# Configure logging -logger = logging.getLogger() -logger.setLevel(logging.INFO) from botocore.config import Config from models.clients.litellm_client import LiteLLMClient from models.domain_objects import CreateModelRequest, InferenceContainer, ModelStatus diff --git a/lambda/models/state_machine/delete_model.py b/lambda/models/state_machine/delete_model.py index cf3c3ec23..e5f4ac9cc 100644 --- a/lambda/models/state_machine/delete_model.py +++ b/lambda/models/state_machine/delete_model.py @@ -22,10 +22,6 @@ from uuid import uuid4 import boto3 - -# Configure logging -logger = logging.getLogger() -logger.setLevel(logging.INFO) from models.clients.litellm_client import LiteLLMClient from utilities.common_functions import get_cert_path, get_rest_api_container_endpoint, retry_config diff --git a/lambda/repository/lambda_functions.py b/lambda/repository/lambda_functions.py index 266f1dcd7..26522ff4c 100644 --- a/lambda/repository/lambda_functions.py +++ b/lambda/repository/lambda_functions.py @@ -28,8 +28,8 @@ from repository.ingestion_service import DocumentIngestionService from repository.rag_document_repo import RagDocumentRepository from repository.vector_store_repo import VectorStoreRepository -from utilities.bedrock_kb import is_bedrock_kb_repository, retrieve_documents from utilities.auth import admin_only, is_admin +from utilities.bedrock_kb import is_bedrock_kb_repository, retrieve_documents from utilities.common_functions import ( api_wrapper, get_cert_path, diff --git a/lambda/repository/pipeline_delete_documents.py b/lambda/repository/pipeline_delete_documents.py index 437e3e0dd..4b6a0e1eb 100644 --- a/lambda/repository/pipeline_delete_documents.py +++ b/lambda/repository/pipeline_delete_documents.py @@ -19,14 +19,13 @@ import boto3 from models.domain_objects import IngestionJob, IngestionStatus, IngestionType from repository.ingestion_job_repo import IngestionJobRepository +from repository.ingestion_service import DocumentIngestionService from repository.pipeline_ingest_documents import remove_document_from_vectorstore +from repository.rag_document_repo import RagDocumentRepository from repository.vector_store_repo import VectorStoreRepository from utilities.bedrock_kb import delete_document_from_kb, is_bedrock_kb_repository from utilities.common_functions import retry_config -from repository.rag_document_repo import RagDocumentRepository -from repository.ingestion_service import DocumentIngestionService - ingestion_service = DocumentIngestionService() ingestion_job_repository = IngestionJobRepository() vs_repo = VectorStoreRepository() diff --git a/lambda/repository/pipeline_ingest_documents.py b/lambda/repository/pipeline_ingest_documents.py index 20f39904f..431b48627 100644 --- a/lambda/repository/pipeline_ingest_documents.py +++ b/lambda/repository/pipeline_ingest_documents.py @@ -21,7 +21,9 @@ import boto3 from models.domain_objects import FixedChunkingStrategy, IngestionJob, IngestionStatus, IngestionType, RagDocument +from repository.embeddings import get_embeddings_pipeline from repository.ingestion_job_repo import IngestionJobRepository +from repository.ingestion_service import DocumentIngestionService from repository.rag_document_repo import RagDocumentRepository from repository.vector_store_repo import VectorStoreRepository from utilities.bedrock_kb import ingest_document_to_kb, is_bedrock_kb_repository @@ -29,9 +31,6 @@ from utilities.file_processing import generate_chunks from utilities.vector_store import get_vector_store_client -from repository.ingestion_service import DocumentIngestionService -from repository.embeddings import get_embeddings_pipeline - dynamodb = boto3.resource("dynamodb", region_name=os.environ["AWS_REGION"], config=retry_config) ingestion_job_table = dynamodb.Table(os.environ["LISA_INGESTION_JOB_TABLE_NAME"]) ingestion_service = DocumentIngestionService() diff --git a/lambda/requirements.txt b/lambda/requirements.txt index a9cb84869..ad8fd1357 100644 --- a/lambda/requirements.txt +++ b/lambda/requirements.txt @@ -1 +1 @@ -# All required depenedencies are pulled in via layer +# All required dependencies are pulled in via layer diff --git a/lambda/utilities/auth.py b/lambda/utilities/auth.py index 789944280..c6f5f5bd6 100644 --- a/lambda/utilities/auth.py +++ b/lambda/utilities/auth.py @@ -11,11 +11,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - import logging +import os from functools import wraps from typing import Any, Callable, Dict +from utilities.common_functions import get_groups from utilities.exceptions import HTTPException logger = logging.getLogger(__name__) @@ -45,4 +46,3 @@ def wrapper(event: Dict[str, Any], context: Dict[str, Any], *args: Any, **kwargs return func(event, context, *args, **kwargs) return wrapper - diff --git a/lambda/utilities/common_functions.py b/lambda/utilities/common_functions.py index 70ff5199e..86651850a 100644 --- a/lambda/utilities/common_functions.py +++ b/lambda/utilities/common_functions.py @@ -21,12 +21,11 @@ import tempfile from contextvars import ContextVar from decimal import Decimal -from functools import cache, wraps +from functools import cache from typing import Any, Callable, cast, Dict, List, TypeVar, Union import boto3 from botocore.config import Config -from utilities.exceptions import HTTPException from . import create_env_variables # noqa type: ignore diff --git a/lib/rag/ingestion/ingestion-job-construct.ts b/lib/rag/ingestion/ingestion-job-construct.ts index 194062d39..3769264e8 100644 --- a/lib/rag/ingestion/ingestion-job-construct.ts +++ b/lib/rag/ingestion/ingestion-job-construct.ts @@ -29,7 +29,6 @@ import * as batch from 'aws-cdk-lib/aws-batch'; import * as ecs from 'aws-cdk-lib/aws-ecs'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as lambda from 'aws-cdk-lib/aws-lambda'; -import { DockerImageAsset } from 'aws-cdk-lib/aws-ecr-assets'; import { Vpc } from '../../networking/vpc'; import path from 'path'; import { ILayerVersion } from 'aws-cdk-lib/aws-lambda'; @@ -38,7 +37,6 @@ import { StringParameter } from 'aws-cdk-lib/aws-ssm'; import * as fs from 'fs'; import * as crypto from 'crypto'; import { BATCH_INGESTION_PATH, CodeFactory } from '../../util'; -import { ContainerImage } from 'aws-cdk-lib/aws-ecs'; // Props interface for the IngestionJobConstruct export type IngestionJobConstructProps = StackProps & BaseProps & { @@ -49,7 +47,7 @@ export type IngestionJobConstructProps = StackProps & BaseProps & { }; export class IngestionJobConstruct extends Construct { - constructor(scope: Construct, id: string, props: IngestionJobConstructProps) { + constructor (scope: Construct, id: string, props: IngestionJobConstructProps) { super(scope, id); const { config, vpc, lambdaRole, layers, baseEnvironment } = props; From 78a9eec86e9b1e90e9938d65ed40f13af9d5a601 Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Sat, 30 Aug 2025 20:59:55 +0000 Subject: [PATCH 16/32] fixing tests and adding code coverage --- lambda/mcp_server/lambda_functions.py | 3 ++- lambda/models/lambda_functions.py | 3 ++- lambda/prompt_templates/lambda_functions.py | 3 ++- lambda/repository/lambda_functions.py | 3 +-- lambda/repository/pipeline_ingest_documents.py | 3 ++- lambda/session/lambda_functions.py | 3 ++- lambda/user_preferences/lambda_functions.py | 3 ++- test/lambda/test_mcp_server_lambda.py | 8 ++++---- test/lambda/test_prompt_templates_lambda.py | 4 ++-- test/lambda/test_repository_lambda.py | 8 ++++---- test/lambda/test_session_lambda.py | 9 ++++++++- test/lambda/test_user_preferences_lambda.py | 2 +- 12 files changed, 32 insertions(+), 20 deletions(-) diff --git a/lambda/mcp_server/lambda_functions.py b/lambda/mcp_server/lambda_functions.py index 6afb00906..ffa58b4bd 100644 --- a/lambda/mcp_server/lambda_functions.py +++ b/lambda/mcp_server/lambda_functions.py @@ -21,7 +21,8 @@ import boto3 from boto3.dynamodb.conditions import Attr, Key -from utilities.common_functions import api_wrapper, get_item, get_username, is_admin, retry_config +from utilities.auth import get_username, is_admin +from utilities.common_functions import api_wrapper, get_item, retry_config from .models import McpServerModel, McpServerStatus diff --git a/lambda/models/lambda_functions.py b/lambda/models/lambda_functions.py index a04f08882..034a89dd0 100644 --- a/lambda/models/lambda_functions.py +++ b/lambda/models/lambda_functions.py @@ -24,7 +24,8 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from mangum import Mangum -from utilities.common_functions import get_groups, is_admin, retry_config +from utilities.auth import is_admin +from utilities.common_functions import get_groups, retry_config from utilities.fastapi_middleware.aws_api_gateway_middleware import AWSAPIGatewayMiddleware from .domain_objects import ( diff --git a/lambda/prompt_templates/lambda_functions.py b/lambda/prompt_templates/lambda_functions.py index 8affa65a0..68fc278e9 100644 --- a/lambda/prompt_templates/lambda_functions.py +++ b/lambda/prompt_templates/lambda_functions.py @@ -22,7 +22,8 @@ import boto3 from boto3.dynamodb.conditions import Attr, Key -from utilities.common_functions import api_wrapper, get_groups, get_item, get_username, is_admin, retry_config +from utilities.auth import get_username, is_admin +from utilities.common_functions import api_wrapper, get_groups, get_item, retry_config from .models import PromptTemplateModel diff --git a/lambda/repository/lambda_functions.py b/lambda/repository/lambda_functions.py index 26522ff4c..a18bbc882 100644 --- a/lambda/repository/lambda_functions.py +++ b/lambda/repository/lambda_functions.py @@ -28,14 +28,13 @@ from repository.ingestion_service import DocumentIngestionService from repository.rag_document_repo import RagDocumentRepository from repository.vector_store_repo import VectorStoreRepository -from utilities.auth import admin_only, is_admin +from utilities.auth import admin_only, get_username, is_admin from utilities.bedrock_kb import is_bedrock_kb_repository, retrieve_documents from utilities.common_functions import ( api_wrapper, get_cert_path, get_groups, get_id_token, - get_username, retry_config, user_has_group_access, ) diff --git a/lambda/repository/pipeline_ingest_documents.py b/lambda/repository/pipeline_ingest_documents.py index 431b48627..d41b30bec 100644 --- a/lambda/repository/pipeline_ingest_documents.py +++ b/lambda/repository/pipeline_ingest_documents.py @@ -26,8 +26,9 @@ from repository.ingestion_service import DocumentIngestionService from repository.rag_document_repo import RagDocumentRepository from repository.vector_store_repo import VectorStoreRepository +from utilities.auth import get_username from utilities.bedrock_kb import ingest_document_to_kb, is_bedrock_kb_repository -from utilities.common_functions import get_username, retry_config +from utilities.common_functions import retry_config from utilities.file_processing import generate_chunks from utilities.vector_store import get_vector_store_client diff --git a/lambda/session/lambda_functions.py b/lambda/session/lambda_functions.py index f580e1337..68fd4648e 100644 --- a/lambda/session/lambda_functions.py +++ b/lambda/session/lambda_functions.py @@ -26,7 +26,8 @@ import boto3 import create_env_variables # noqa: F401 from botocore.exceptions import ClientError -from utilities.common_functions import api_wrapper, get_groups, get_session_id, get_username, retry_config +from utilities.auth import get_username +from utilities.common_functions import api_wrapper, get_groups, get_session_id, retry_config from utilities.encoders import convert_decimal logger = logging.getLogger(__name__) diff --git a/lambda/user_preferences/lambda_functions.py b/lambda/user_preferences/lambda_functions.py index de55a29b2..cae6e3e09 100644 --- a/lambda/user_preferences/lambda_functions.py +++ b/lambda/user_preferences/lambda_functions.py @@ -21,7 +21,8 @@ import boto3 from boto3.dynamodb.conditions import Key -from utilities.common_functions import api_wrapper, get_item, get_username, retry_config +from utilities.auth import get_username +from utilities.common_functions import api_wrapper, get_item, retry_config from .models import UserPreferencesModel diff --git a/test/lambda/test_mcp_server_lambda.py b/test/lambda/test_mcp_server_lambda.py index 6d9e9d4f1..b31b4d141 100644 --- a/test/lambda/test_mcp_server_lambda.py +++ b/test/lambda/test_mcp_server_lambda.py @@ -81,8 +81,8 @@ def wrapper(*args, **kwargs): # Setup patches without .start() to avoid global interference patches = [ patch.dict("sys.modules", {"create_env_variables": mock_create_env}), - patch("utilities.common_functions.get_username", mock_common.get_username), - patch("utilities.common_functions.is_admin", mock_common.is_admin), + patch("utilities.auth.get_username", mock_common.get_username), + patch("utilities.auth.is_admin", mock_common.is_admin), patch("utilities.common_functions.retry_config", retry_config), patch("utilities.common_functions.api_wrapper", mock_api_wrapper), ] @@ -103,8 +103,8 @@ def wrapper(*args, **kwargs): def setup_mcp_mocks(): """Setup mocks for MCP server tests with proper cleanup.""" patches = [ - patch("utilities.common_functions.get_username", mock_common.get_username), - patch("utilities.common_functions.is_admin", mock_common.is_admin), + patch("utilities.auth.get_username", mock_common.get_username), + patch("utilities.auth.is_admin", mock_common.is_admin), patch("utilities.common_functions.retry_config", retry_config), patch("utilities.common_functions.api_wrapper", mock_api_wrapper), ] diff --git a/test/lambda/test_prompt_templates_lambda.py b/test/lambda/test_prompt_templates_lambda.py index c9b22878d..7cc28c211 100644 --- a/test/lambda/test_prompt_templates_lambda.py +++ b/test/lambda/test_prompt_templates_lambda.py @@ -112,9 +112,9 @@ def wrapper(event, context): ).start() # Then patch the specific functions -patch("utilities.common_functions.get_username", mock_common.get_username).start() +patch("utilities.auth.get_username", mock_common.get_username).start() patch("utilities.common_functions.get_groups", mock_common.get_groups).start() -patch("utilities.common_functions.is_admin", mock_common.is_admin).start() +patch("utilities.auth.is_admin", mock_common.is_admin).start() patch("utilities.common_functions.retry_config", retry_config).start() patch("utilities.common_functions.api_wrapper", mock_api_wrapper).start() # Patch the API wrapper diff --git a/test/lambda/test_repository_lambda.py b/test/lambda/test_repository_lambda.py index a12abeb28..adf5d7919 100644 --- a/test/lambda/test_repository_lambda.py +++ b/test/lambda/test_repository_lambda.py @@ -237,15 +237,15 @@ def mock_boto3_client(service_name, region_name=None, config=None): }, ).start() -# Patch specific functions from utilities.common_functions -patch("utilities.common_functions.get_username", mock_common.get_username).start() +# Patch specific functions from utilities.common_functions and utilities.auth +patch("utilities.auth.get_username", mock_common.get_username).start() patch("utilities.common_functions.get_groups", mock_common.get_groups).start() -patch("utilities.common_functions.is_admin", mock_common.is_admin).start() +patch("utilities.auth.is_admin", mock_common.is_admin).start() patch("utilities.common_functions.retry_config", retry_config).start() patch("utilities.common_functions.api_wrapper", mock_api_wrapper).start() patch("utilities.common_functions.get_id_token", mock_common.get_id_token).start() patch("utilities.common_functions.get_cert_path", mock_common.get_cert_path).start() -patch("utilities.common_functions.admin_only", mock_admin_only).start() +patch("utilities.auth.admin_only", mock_admin_only).start() # Patch utility functions patch("utilities.vector_store.get_vector_store_client", mock_get_vector_store_client).start() diff --git a/test/lambda/test_session_lambda.py b/test/lambda/test_session_lambda.py index e83426adf..a6ca0fb3b 100644 --- a/test/lambda/test_session_lambda.py +++ b/test/lambda/test_session_lambda.py @@ -38,6 +38,7 @@ os.environ["SESSIONS_TABLE_NAME"] = "sessions-table" os.environ["SESSIONS_BY_USER_ID_INDEX_NAME"] = "sessions-by-user-id-index" os.environ["GENERATED_IMAGES_S3_BUCKET_NAME"] = "bucket" +os.environ["MODEL_TABLE_NAME"] = "model-table" # Create a real retry config retry_config = Config(retries=dict(max_attempts=3), defaults_mode="standard") @@ -55,6 +56,12 @@ def wrapper(*args, **kwargs): "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"}, "body": json.dumps(result, default=str), } + except ValueError as e: + return { + "statusCode": 400, + "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"}, + "body": json.dumps({"error": str(e)}), + } except Exception as e: logging.error(f"Error in {func.__name__}: {str(e)}") return { @@ -129,7 +136,7 @@ def dynamodb_table(dynamodb): ).start() # Then patch the specific functions -patch("utilities.common_functions.get_username", mock_common.get_username).start() +patch("utilities.auth.get_username", mock_common.get_username).start() patch("utilities.common_functions.get_session_id", mock_common.get_session_id).start() patch("utilities.common_functions.retry_config", retry_config).start() patch("utilities.common_functions.api_wrapper", mock_api_wrapper).start() diff --git a/test/lambda/test_user_preferences_lambda.py b/test/lambda/test_user_preferences_lambda.py index 04f0bfbc4..787c408d9 100644 --- a/test/lambda/test_user_preferences_lambda.py +++ b/test/lambda/test_user_preferences_lambda.py @@ -103,7 +103,7 @@ def wrapper(*args, **kwargs): ).start() # Then patch the specific functions -patch("utilities.common_functions.get_username", mock_common.get_username).start() +patch("utilities.auth.get_username", mock_common.get_username).start() patch("utilities.common_functions.retry_config", retry_config).start() patch("utilities.common_functions.api_wrapper", mock_api_wrapper).start() patch("utilities.common_functions.get_item", mock_common.get_item).start() From 58c0885bd00126757c71f278ace89ddd9d053e9d Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Sat, 30 Aug 2025 21:00:27 +0000 Subject: [PATCH 17/32] fixing tests and adding code coverage --- test/lambda/test_encoders.py | 247 +++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 test/lambda/test_encoders.py diff --git a/test/lambda/test_encoders.py b/test/lambda/test_encoders.py new file mode 100644 index 000000000..f5650ac0d --- /dev/null +++ b/test/lambda/test_encoders.py @@ -0,0 +1,247 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys +from decimal import Decimal + +# Set up mock AWS credentials first +os.environ["AWS_ACCESS_KEY_ID"] = "testing" +os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" +os.environ["AWS_SECURITY_TOKEN"] = "testing" +os.environ["AWS_SESSION_TOKEN"] = "testing" +os.environ["AWS_DEFAULT_REGION"] = "us-east-1" +os.environ["AWS_REGION"] = "us-east-1" + +# Add the lambda directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../")) + +from utilities.encoders import convert_decimal + + +def test_convert_decimal_with_decimal(): + """Test convert_decimal with Decimal values""" + # Test single Decimal + result = convert_decimal(Decimal("123.45")) + assert result == 123.45 + assert isinstance(result, float) + + # Test Decimal zero + result = convert_decimal(Decimal("0")) + assert result == 0.0 + assert isinstance(result, float) + + # Test negative Decimal + result = convert_decimal(Decimal("-456.78")) + assert result == -456.78 + assert isinstance(result, float) + + +def test_convert_decimal_with_dict(): + """Test convert_decimal with dictionary containing Decimals""" + input_dict = { + "price": Decimal("99.99"), + "quantity": Decimal("5"), + "name": "Product", + "active": True, + "nested": { + "cost": Decimal("50.25"), + "tax": Decimal("7.5") + } + } + + result = convert_decimal(input_dict) + + assert result["price"] == 99.99 + assert isinstance(result["price"], float) + assert result["quantity"] == 5.0 + assert isinstance(result["quantity"], float) + assert result["name"] == "Product" + assert result["active"] is True + assert result["nested"]["cost"] == 50.25 + assert isinstance(result["nested"]["cost"], float) + assert result["nested"]["tax"] == 7.5 + assert isinstance(result["nested"]["tax"], float) + + +def test_convert_decimal_with_list(): + """Test convert_decimal with list containing Decimals""" + input_list = [ + Decimal("10.5"), + "string_value", + 42, + [Decimal("3.14"), "nested"], + {"amount": Decimal("100.00")} + ] + + result = convert_decimal(input_list) + + assert result[0] == 10.5 + assert isinstance(result[0], float) + assert result[1] == "string_value" + assert result[2] == 42 + assert result[3][0] == 3.14 + assert isinstance(result[3][0], float) + assert result[3][1] == "nested" + assert result[4]["amount"] == 100.0 + assert isinstance(result[4]["amount"], float) + + +def test_convert_decimal_with_non_decimal_types(): + """Test convert_decimal with non-Decimal types (should pass through unchanged)""" + # Test string + result = convert_decimal("hello") + assert result == "hello" + + # Test integer + result = convert_decimal(42) + assert result == 42 + + # Test float + result = convert_decimal(3.14) + assert result == 3.14 + + # Test boolean + result = convert_decimal(True) + assert result is True + + # Test None + result = convert_decimal(None) + assert result is None + + +def test_convert_decimal_with_empty_collections(): + """Test convert_decimal with empty dict and list""" + # Test empty dict + result = convert_decimal({}) + assert result == {} + + # Test empty list + result = convert_decimal([]) + assert result == [] + + +def test_convert_decimal_with_complex_nested_structure(): + """Test convert_decimal with deeply nested structure""" + complex_data = { + "users": [ + { + "id": 1, + "balance": Decimal("1000.50"), + "transactions": [ + {"amount": Decimal("50.25"), "type": "debit"}, + {"amount": Decimal("100.75"), "type": "credit"} + ], + "metadata": { + "scores": [Decimal("95.5"), Decimal("87.2")], + "rating": Decimal("4.8") + } + } + ], + "totals": { + "sum": Decimal("2000.00"), + "average": Decimal("666.67") + } + } + + result = convert_decimal(complex_data) + + # Check user balance + assert result["users"][0]["balance"] == 1000.50 + assert isinstance(result["users"][0]["balance"], float) + + # Check transaction amounts + assert result["users"][0]["transactions"][0]["amount"] == 50.25 + assert isinstance(result["users"][0]["transactions"][0]["amount"], float) + assert result["users"][0]["transactions"][1]["amount"] == 100.75 + assert isinstance(result["users"][0]["transactions"][1]["amount"], float) + + # Check metadata scores + assert result["users"][0]["metadata"]["scores"][0] == 95.5 + assert isinstance(result["users"][0]["metadata"]["scores"][0], float) + assert result["users"][0]["metadata"]["scores"][1] == 87.2 + assert isinstance(result["users"][0]["metadata"]["scores"][1], float) + + # Check metadata rating + assert result["users"][0]["metadata"]["rating"] == 4.8 + assert isinstance(result["users"][0]["metadata"]["rating"], float) + + # Check totals + assert result["totals"]["sum"] == 2000.0 + assert isinstance(result["totals"]["sum"], float) + assert result["totals"]["average"] == 666.67 + assert isinstance(result["totals"]["average"], float) + + # Check non-Decimal values remain unchanged + assert result["users"][0]["id"] == 1 + assert result["users"][0]["transactions"][0]["type"] == "debit" + assert result["users"][0]["transactions"][1]["type"] == "credit" + + +def test_convert_decimal_with_mixed_types_in_list(): + """Test convert_decimal with list containing mixed types including nested structures""" + mixed_list = [ + Decimal("123.45"), + {"price": Decimal("99.99"), "name": "Item"}, + [Decimal("1.1"), Decimal("2.2"), "text"], + "plain_string", + 42, + None, + True + ] + + result = convert_decimal(mixed_list) + + assert result[0] == 123.45 + assert isinstance(result[0], float) + assert result[1]["price"] == 99.99 + assert isinstance(result[1]["price"], float) + assert result[1]["name"] == "Item" + assert result[2][0] == 1.1 + assert isinstance(result[2][0], float) + assert result[2][1] == 2.2 + assert isinstance(result[2][1], float) + assert result[2][2] == "text" + assert result[3] == "plain_string" + assert result[4] == 42 + assert result[5] is None + assert result[6] is True + + +def test_convert_decimal_preserves_original_structure(): + """Test that convert_decimal preserves the original data structure""" + original = { + "level1": { + "level2": { + "level3": [ + {"value": Decimal("42.0")}, + {"value": Decimal("84.0")} + ] + } + } + } + + result = convert_decimal(original) + + # Check structure is preserved + assert "level1" in result + assert "level2" in result["level1"] + assert "level3" in result["level1"]["level2"] + assert len(result["level1"]["level2"]["level3"]) == 2 + + # Check values are converted + assert result["level1"]["level2"]["level3"][0]["value"] == 42.0 + assert isinstance(result["level1"]["level2"]["level3"][0]["value"], float) + assert result["level1"]["level2"]["level3"][1]["value"] == 84.0 + assert isinstance(result["level1"]["level2"]["level3"][1]["value"], float) From 45f3180323b8fab4853dda551d065bdcc3897085 Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Sun, 31 Aug 2025 20:22:05 +0000 Subject: [PATCH 18/32] Add auth test --- test/lambda/test_auth.py | 552 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 552 insertions(+) create mode 100644 test/lambda/test_auth.py diff --git a/test/lambda/test_auth.py b/test/lambda/test_auth.py new file mode 100644 index 000000000..903d60458 --- /dev/null +++ b/test/lambda/test_auth.py @@ -0,0 +1,552 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import os +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +# Add the lambda directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../")) + +# Set up mock AWS credentials +os.environ["AWS_ACCESS_KEY_ID"] = "testing" +os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" +os.environ["AWS_SECURITY_TOKEN"] = "testing" +os.environ["AWS_SESSION_TOKEN"] = "testing" +os.environ["AWS_DEFAULT_REGION"] = "us-east-1" + + +@pytest.fixture +def lambda_context(): + """Create a mock Lambda context.""" + return SimpleNamespace( + function_name="test_function", + function_version="$LATEST", + invoked_function_arn="arn:aws:lambda:us-east-1:123456789012:function:test_function", + memory_limit_in_mb=128, + aws_request_id="test-request-id", + log_group_name="/aws/lambda/test_function", + log_stream_name="2024/03/27/[$LATEST]test123", + ) + + + + + +@pytest.fixture +def sample_event_with_username(): + """Create a sample event with username in requestContext.""" + return { + "requestContext": { + "authorizer": { + "username": "test-user", + "groups": '["group1", "group2"]' + } + } + } + + +@pytest.fixture +def sample_event_without_username(): + """Create a sample event without username in requestContext.""" + return { + "requestContext": { + "authorizer": { + "groups": '["group1", "group2"]' + } + } + } + + +@pytest.fixture +def sample_event_empty_context(): + """Create a sample event with empty requestContext.""" + return { + "requestContext": {} + } + + +@pytest.fixture +def sample_event_no_context(): + """Create a sample event without requestContext.""" + return {} + + +def test_get_username_with_valid_username(sample_event_with_username): + """Test getting username when username is present in event.""" + # Clean up any existing modules + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + from utilities.auth import get_username + + username = get_username(sample_event_with_username) + assert username == "test-user" + +def test_get_username_without_username(sample_event_without_username): + """Test getting username when username is not present in event.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + from utilities.auth import get_username + + username = get_username(sample_event_without_username) + assert username == "system" + +def test_get_username_empty_context(sample_event_empty_context): + """Test getting username when requestContext is empty.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + from utilities.auth import get_username + + username = get_username(sample_event_empty_context) + assert username == "system" + +def test_get_username_no_context(sample_event_no_context): + """Test getting username when requestContext is missing.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + from utilities.auth import get_username + + username = get_username(sample_event_no_context) + assert username == "system" + +def test_get_username_nested_missing_authorizer(): + """Test getting username when authorizer is missing from requestContext.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + from utilities.auth import get_username + + event = { + "requestContext": { + "other_field": "value" + } + } + username = get_username(event) + assert username == "system" + + + +def test_is_admin_with_admin_group(sample_event_with_username): + """Test is_admin when user has admin group.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): + with patch("utilities.auth.get_groups", return_value=["group1", "admin", "group2"]): + from utilities.auth import is_admin + result = is_admin(sample_event_with_username) + assert result is True + +def test_is_admin_without_admin_group(sample_event_with_username): + """Test is_admin when user does not have admin group.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): + with patch("utilities.auth.get_groups", return_value=["group1", "group2"]): + from utilities.auth import is_admin + result = is_admin(sample_event_with_username) + assert result is False + +def test_is_admin_empty_groups(sample_event_with_username): + """Test is_admin when user has no groups.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): + with patch("utilities.auth.get_groups", return_value=[]): + from utilities.auth import is_admin + result = is_admin(sample_event_with_username) + assert result is False + +def test_is_admin_empty_admin_group_env(sample_event_with_username): + """Test is_admin when ADMIN_GROUP environment variable is empty.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch.dict(os.environ, {"ADMIN_GROUP": ""}): + with patch("utilities.auth.get_groups", return_value=["group1", "group2"]): + from utilities.auth import is_admin + result = is_admin(sample_event_with_username) + assert result is False + +def test_is_admin_no_admin_group_env(sample_event_with_username): + """Test is_admin when ADMIN_GROUP environment variable is not set.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + # Temporarily remove ADMIN_GROUP if it exists + original_admin_group = os.environ.get("ADMIN_GROUP") + if "ADMIN_GROUP" in os.environ: + del os.environ["ADMIN_GROUP"] + + try: + with patch("utilities.auth.get_groups", return_value=["group1", "group2"]): + from utilities.auth import is_admin + result = is_admin(sample_event_with_username) + assert result is False + finally: + # Restore original value if it existed + if original_admin_group is not None: + os.environ["ADMIN_GROUP"] = original_admin_group + +def test_is_admin_logging(sample_event_with_username): + """Test is_admin logs the groups and admin group.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): + with patch("utilities.auth.get_groups", return_value=["group1", "admin"]): + with patch("utilities.auth.logger") as mock_logger: + from utilities.auth import is_admin + is_admin(sample_event_with_username) + mock_logger.info.assert_called_once_with("User groups: ['group1', 'admin'] and admin: admin") + + +def test_admin_only_decorator_with_admin_user(lambda_context): + """Test admin_only decorator allows admin users to access function.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch("utilities.auth.is_admin", return_value=True): + from utilities.auth import admin_only + + @admin_only + def test_function(event, context): + return {"result": "success"} + + event = {"test": "data"} + result = test_function(event, lambda_context) + + assert result == {"result": "success"} + +def test_admin_only_decorator_with_non_admin_user(lambda_context): + """Test admin_only decorator raises HTTPException for non-admin users.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch("utilities.auth.is_admin", return_value=False): + from utilities.auth import admin_only + from utilities.exceptions import HTTPException + + @admin_only + def test_function(event, context): + return {"result": "success"} + + event = {"test": "data"} + + with pytest.raises(HTTPException) as exc_info: + test_function(event, lambda_context) + + assert exc_info.value.http_status_code == 403 + assert exc_info.value.message == "User does not have permission to access this repository" + +def test_admin_only_decorator_preserves_function_metadata(): + """Test admin_only decorator preserves original function metadata.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + from utilities.auth import admin_only + + @admin_only + def test_function(event, context): + """Test function docstring.""" + return {"result": "success"} + + assert test_function.__name__ == "test_function" + assert test_function.__doc__ == "Test function docstring." + +def test_admin_only_decorator_with_function_args_kwargs(lambda_context): + """Test admin_only decorator works with functions that have additional args.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch("utilities.auth.is_admin", return_value=True): + from utilities.auth import admin_only + + @admin_only + def test_function(event, context, *args, **kwargs): + return {"result": "success", "args": args, "kwargs": kwargs} + + event = {"test": "data"} + result = test_function(event, lambda_context, "arg1", "arg2", key1="value1") + + assert result["result"] == "success" + assert result["args"] == ("arg1", "arg2") + assert result["kwargs"] == {"key1": "value1"} + + +def test_admin_only_decorator_function_raises_exception(lambda_context): + """Test admin_only decorator handles exceptions from wrapped function.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch("utilities.auth.is_admin", return_value=True): + from utilities.auth import admin_only + + @admin_only + def test_function(event, context): + raise ValueError("Test error") + + event = {"test": "data"} + + with pytest.raises(ValueError, match="Test error"): + test_function(event, lambda_context) + + +def test_admin_only_decorator_calls_is_admin_with_event(lambda_context): + """Test admin_only decorator calls is_admin with the event.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch("utilities.auth.is_admin") as mock_is_admin: + mock_is_admin.return_value = True + from utilities.auth import admin_only + + @admin_only + def test_function(event, context): + return {"result": "success"} + + event = {"test": "data"} + test_function(event, lambda_context) + mock_is_admin.assert_called_once_with(event) + + +def test_full_auth_flow_admin_user(): + """Test full authentication flow for admin user.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + event = { + "requestContext": { + "authorizer": { + "username": "admin-user", + "groups": '["admin", "users"]' + } + } + } + + with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): + with patch("utilities.auth.get_groups", return_value=["admin", "users"]): + from utilities.auth import get_username, is_admin + username = get_username(event) + admin_status = is_admin(event) + + assert username == "admin-user" + assert admin_status is True + + +def test_full_auth_flow_regular_user(): + """Test full authentication flow for regular user.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + event = { + "requestContext": { + "authorizer": { + "username": "regular-user", + "groups": '["users"]' + } + } + } + + with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): + with patch("utilities.auth.get_groups", return_value=["users"]): + from utilities.auth import get_username, is_admin + username = get_username(event) + admin_status = is_admin(event) + + assert username == "regular-user" + assert admin_status is False + + +def test_system_user_auth_flow(): + """Test authentication flow for system user (no context).""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + event = {} + + with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): + with patch("utilities.auth.get_groups", return_value=[]): + from utilities.auth import get_username, is_admin + username = get_username(event) + admin_status = is_admin(event) + + assert username == "system" + assert admin_status is False + + +def test_admin_only_decorator_with_extra_args(lambda_context): + """Test admin_only decorator with additional args and kwargs.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch("utilities.auth.is_admin", return_value=True): + from utilities.auth import admin_only + + @admin_only + def test_function(event, context, extra_arg, extra_kwarg=None): + return { + "result": "success", + "extra_arg": extra_arg, + "extra_kwarg": extra_kwarg + } + + event = {"test": "data"} + result = test_function(event, lambda_context, "test_arg", extra_kwarg="test_kwarg") + + assert result == { + "result": "success", + "extra_arg": "test_arg", + "extra_kwarg": "test_kwarg" + } + + +def test_admin_only_decorator_function_exception_propagation(lambda_context): + """Test admin_only decorator allows underlying function exceptions to propagate.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch("utilities.auth.is_admin", return_value=True): + from utilities.auth import admin_only + + @admin_only + def test_function(event, context): + raise ValueError("Function error") + + event = {"test": "data"} + + with pytest.raises(ValueError) as exc_info: + test_function(event, lambda_context) + + assert str(exc_info.value) == "Function error" + + +def test_admin_only_decorator_event_passing(lambda_context): + """Test admin_only decorator calls is_admin with the correct event.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + with patch("utilities.auth.is_admin", return_value=True) as mock_is_admin: + from utilities.auth import admin_only + + @admin_only + def test_function(event, context): + return {"result": "success"} + + event = {"test": "data"} + test_function(event, lambda_context) + + mock_is_admin.assert_called_once_with(event) + + +def test_complete_auth_flow_admin_user(lambda_context): + """Test complete auth flow for an admin user.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + event = { + "requestContext": { + "authorizer": { + "username": "admin_user", + "groups": '["user", "lisa-admin"]' + } + } + } + + from utilities.auth import admin_only, get_username, is_admin + + # Test get_username + username = get_username(event) + assert username == "admin_user" + + # Test is_admin (need to mock get_groups) + with patch.dict(os.environ, {"ADMIN_GROUP": "lisa-admin"}): + with patch("utilities.auth.get_groups", return_value=["user", "lisa-admin"]): + admin_status = is_admin(event) + assert admin_status is True + + # Test admin_only decorator + @admin_only + def admin_function(event, context): + return {"user": get_username(event), "admin": True} + + result = admin_function(event, lambda_context) + assert result == {"user": "admin_user", "admin": True} + + +def test_complete_auth_flow_regular_user(lambda_context): + """Test complete auth flow for a regular user.""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + event = { + "requestContext": { + "authorizer": { + "username": "regular_user", + "groups": '["user"]' + } + } + } + + from utilities.auth import admin_only, get_username, is_admin + from utilities.exceptions import HTTPException + + # Test get_username + username = get_username(event) + assert username == "regular_user" + + # Test is_admin (need to mock get_groups) + with patch.dict(os.environ, {"ADMIN_GROUP": "lisa-admin"}): + with patch("utilities.auth.get_groups", return_value=["user"]): + admin_status = is_admin(event) + assert admin_status is False + + # Test admin_only decorator + @admin_only + def admin_function(event, context): + return {"user": get_username(event), "admin": True} + + with pytest.raises(HTTPException) as exc_info: + admin_function(event, lambda_context) + + assert exc_info.value.http_status_code == 403 + + +def test_complete_system_user_auth_flow(lambda_context): + """Test auth flow for system user (no context).""" + if 'utilities.auth' in sys.modules: + del sys.modules['utilities.auth'] + + event = {} + + from utilities.auth import get_username, is_admin + + # Test get_username + username = get_username(event) + assert username == "system" + + # Test is_admin with system user + with patch("utilities.auth.get_groups", return_value=[]): + admin_status = is_admin(event) + assert admin_status is False From 2e67aab26bac7ee0d83cfac256197470bc1e27bf Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Sun, 31 Aug 2025 21:03:19 +0000 Subject: [PATCH 19/32] Add code coverage --- test/lambda/test_common_functions.py | 174 +++++++++++++++++++++++++++ test/lambda/test_litellm.py | 97 +++++++++++++++ test/lambda/test_validation.py | 74 ++++++++++++ test/lambda/test_validators.py | 73 +++++++++++ test/lambda/test_vector_store.py | 114 ++++++++++++++++++ 5 files changed, 532 insertions(+) create mode 100644 test/lambda/test_common_functions.py create mode 100644 test/lambda/test_litellm.py create mode 100644 test/lambda/test_validation.py create mode 100644 test/lambda/test_validators.py create mode 100644 test/lambda/test_vector_store.py diff --git a/test/lambda/test_common_functions.py b/test/lambda/test_common_functions.py new file mode 100644 index 000000000..0beac7a95 --- /dev/null +++ b/test/lambda/test_common_functions.py @@ -0,0 +1,174 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import json +import os +import sys +from decimal import Decimal +from unittest.mock import patch + +import pytest + +# Add the lambda directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../")) + +# Set up mock AWS credentials +os.environ["AWS_ACCESS_KEY_ID"] = "testing" +os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" +os.environ["AWS_SECURITY_TOKEN"] = "testing" +os.environ["AWS_SESSION_TOKEN"] = "testing" +os.environ["AWS_DEFAULT_REGION"] = "us-east-1" +os.environ["AWS_REGION"] = "us-east-1" + + +def test_decimal_encoder(): + """Test DecimalEncoder converts Decimal to float.""" + if 'utilities.common_functions' in sys.modules: + del sys.modules['utilities.common_functions'] + + from utilities.common_functions import DecimalEncoder + + encoder = DecimalEncoder() + result = encoder.default(Decimal('10.5')) + assert result == 10.5 + + +def test_generate_html_response(): + """Test generate_html_response creates proper response.""" + if 'utilities.common_functions' in sys.modules: + del sys.modules['utilities.common_functions'] + + from utilities.common_functions import generate_html_response + + response = generate_html_response(200, {"message": "success"}) + + assert response["statusCode"] == 200 + assert json.loads(response["body"]) == {"message": "success"} + assert response["headers"]["Access-Control-Allow-Origin"] == "*" + + +def test_get_username(): + """Test get_username extracts username from event.""" + if 'utilities.common_functions' in sys.modules: + del sys.modules['utilities.common_functions'] + + from utilities.common_functions import get_username + + event = { + "requestContext": { + "authorizer": { + "username": "test-user" + } + } + } + + username = get_username(event) + assert username == "test-user" + + +def test_get_username_default(): + """Test get_username returns system when username missing.""" + if 'utilities.common_functions' in sys.modules: + del sys.modules['utilities.common_functions'] + + from utilities.common_functions import get_username + + event = {} + + username = get_username(event) + assert username == "system" + + +def test_user_has_group_access_public(): + """Test user_has_group_access returns True for public resources.""" + if 'utilities.common_functions' in sys.modules: + del sys.modules['utilities.common_functions'] + + from utilities.common_functions import user_has_group_access + + result = user_has_group_access(["user"], []) + assert result is True + + +def test_user_has_group_access_matching(): + """Test user_has_group_access returns True when user has matching group.""" + if 'utilities.common_functions' in sys.modules: + del sys.modules['utilities.common_functions'] + + from utilities.common_functions import user_has_group_access + + result = user_has_group_access(["admin", "user"], ["admin"]) + assert result is True + + +def test_merge_fields_top_level(): + """Test merge_fields with top-level fields.""" + if 'utilities.common_functions' in sys.modules: + del sys.modules['utilities.common_functions'] + + from utilities.common_functions import merge_fields + + source = {"name": "John", "age": 30, "city": "NYC"} + target = {"country": "USA"} + fields = ["name", "age"] + + result = merge_fields(source, target, fields) + + assert result["name"] == "John" + assert result["age"] == 30 + assert result["country"] == "USA" + assert "city" not in result + + +def test_validate_model_name_valid(): + """Test validate_model_name with valid model name.""" + if 'utilities.validation' in sys.modules: + del sys.modules['utilities.validation'] + + from utilities.validation import validate_model_name + + result = validate_model_name("valid-model-123") + assert result is True + + +def test_validate_model_name_empty(): + """Test validate_model_name raises ValidationError for empty string.""" + if 'utilities.validation' in sys.modules: + del sys.modules['utilities.validation'] + + from utilities.validation import validate_model_name, ValidationError + + with pytest.raises(ValidationError): + validate_model_name("") + + +def test_validate_instance_type_valid(): + """Test validate_instance_type with valid EC2 instance type.""" + if 'utilities.validators' in sys.modules: + del sys.modules['utilities.validators'] + + from utilities.validators import validate_instance_type + + result = validate_instance_type("t3.micro") + assert result == "t3.micro" + + +def test_validate_all_fields_defined_true(): + """Test validate_all_fields_defined returns True when all fields are non-null.""" + if 'utilities.validators' in sys.modules: + del sys.modules['utilities.validators'] + + from utilities.validators import validate_all_fields_defined + + result = validate_all_fields_defined(["value1", "value2", "value3"]) + assert result is True + + +def test_validate_all_fields_defined_false(): + """Test validate_all_fields_defined returns False when any field is None.""" + if 'utilities.validators' in sys.modules: + del sys.modules['utilities.validators'] + + from utilities.validators import validate_all_fields_defined + + result = validate_all_fields_defined(["value1", None, "value3"]) + assert result is False \ No newline at end of file diff --git a/test/lambda/test_litellm.py b/test/lambda/test_litellm.py new file mode 100644 index 000000000..b562884e3 --- /dev/null +++ b/test/lambda/test_litellm.py @@ -0,0 +1,97 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +# Add the lambda directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../")) + +# Set up mock AWS credentials +os.environ["AWS_ACCESS_KEY_ID"] = "testing" +os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" +os.environ["AWS_DEFAULT_REGION"] = "us-east-1" + + +def test_litellm_client_basic(): + """Test basic LiteLLMClient functionality.""" + if 'models.clients.litellm_client' in sys.modules: + del sys.modules['models.clients.litellm_client'] + + from starlette.datastructures import Headers + from models.clients.litellm_client import LiteLLMClient + + headers = Headers({"Authorization": "Bearer test-token"}) + client = LiteLLMClient( + base_uri="https://api.example.com", + headers=headers, + verify=True, + timeout=30 + ) + + # Test that attributes are set correctly + assert hasattr(client, '_base_uri') + assert hasattr(client, '_headers') + assert hasattr(client, '_verify') + assert hasattr(client, '_timeout') + + +@patch('requests.get') +def test_list_models_basic(mock_get): + """Test list_models basic functionality.""" + if 'models.clients.litellm_client' in sys.modules: + del sys.modules['models.clients.litellm_client'] + + from starlette.datastructures import Headers + from models.clients.litellm_client import LiteLLMClient + + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + headers = Headers({}) + client = LiteLLMClient("https://api.example.com", headers, True, 30) + + result = client.list_models() + assert isinstance(result, list) + + +@patch('requests.post') +def test_add_model_basic(mock_post): + """Test add_model basic functionality.""" + if 'models.clients.litellm_client' in sys.modules: + del sys.modules['models.clients.litellm_client'] + + from starlette.datastructures import Headers + from models.clients.litellm_client import LiteLLMClient + + mock_response = MagicMock() + mock_response.json.return_value = {"status": "ok"} + mock_post.return_value = mock_response + + headers = Headers({}) + client = LiteLLMClient("https://api.example.com", headers, True, 30) + + result = client.add_model("test-model", {"model": "gpt-3.5-turbo"}) + assert "status" in result + + +def test_get_model_not_found_basic(): + """Test get_model raises ModelNotFoundError when model doesn't exist.""" + if 'models.clients.litellm_client' in sys.modules: + del sys.modules['models.clients.litellm_client'] + if 'models.exception' in sys.modules: + del sys.modules['models.exception'] + + from starlette.datastructures import Headers + from models.clients.litellm_client import LiteLLMClient + from models.exception import ModelNotFoundError + + headers = Headers({}) + client = LiteLLMClient("https://api.example.com", headers, True, 30) + + with patch.object(client, 'list_models', return_value=[]): + with pytest.raises(ModelNotFoundError): + client.get_model("nonexistent-model") \ No newline at end of file diff --git a/test/lambda/test_validation.py b/test/lambda/test_validation.py new file mode 100644 index 000000000..c45f8a860 --- /dev/null +++ b/test/lambda/test_validation.py @@ -0,0 +1,74 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import os +import sys + +import pytest + +# Add the lambda directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../")) + + +def test_validate_model_name_valid(): + """Test validate_model_name with valid model name.""" + from utilities.validation import validate_model_name + + result = validate_model_name("valid-model-123") + assert result is True + + +def test_validate_model_name_empty(): + """Test validate_model_name raises ValidationError for empty string.""" + from utilities.validation import validate_model_name, ValidationError + + with pytest.raises(ValidationError, match="Model name cannot be empty"): + validate_model_name("") + + +def test_validate_model_name_whitespace(): + """Test validate_model_name raises ValidationError for whitespace.""" + from utilities.validation import validate_model_name, ValidationError + + with pytest.raises(ValidationError, match="Model name cannot be empty"): + validate_model_name(" ") + + +def test_validate_model_name_not_string(): + """Test validate_model_name raises ValidationError for non-string.""" + from utilities.validation import validate_model_name, ValidationError + + with pytest.raises(ValidationError, match="Model name must be a string"): + validate_model_name(123) + + +def test_safe_error_response_validation_error(): + """Test safe_error_response with ValidationError.""" + from utilities.validation import safe_error_response, ValidationError + + error = ValidationError("Invalid input") + response = safe_error_response(error) + + assert response["statusCode"] == 400 + assert response["body"]["message"] == "Invalid input" + + +def test_safe_error_response_security_error(): + """Test safe_error_response with SecurityError.""" + from utilities.validation import safe_error_response, SecurityError + + error = SecurityError("Access denied") + response = safe_error_response(error) + + assert response["statusCode"] == 403 + assert response["body"]["message"] == "Security validation failed" + + +def test_safe_error_response_generic_error(): + """Test safe_error_response with generic Exception.""" + from utilities.validation import safe_error_response + + error = Exception("Internal error") + response = safe_error_response(error) + + assert response["statusCode"] == 500 + assert response["body"]["message"] == "Internal server error" \ No newline at end of file diff --git a/test/lambda/test_validators.py b/test/lambda/test_validators.py new file mode 100644 index 000000000..94ac7b61f --- /dev/null +++ b/test/lambda/test_validators.py @@ -0,0 +1,73 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import os +import sys + +import pytest + +# Add the lambda directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../")) + + +def test_validate_instance_type_valid(): + """Test validate_instance_type with valid EC2 instance type.""" + from utilities.validators import validate_instance_type + + result = validate_instance_type("t3.micro") + assert result == "t3.micro" + + +def test_validate_instance_type_invalid(): + """Test validate_instance_type with invalid instance type.""" + from utilities.validators import validate_instance_type + + with pytest.raises(ValueError, match="Invalid EC2 instance type"): + validate_instance_type("invalid-type") + + +def test_validate_all_fields_defined_true(): + """Test validate_all_fields_defined returns True when all fields are non-null.""" + from utilities.validators import validate_all_fields_defined + + result = validate_all_fields_defined(["value1", "value2", "value3"]) + assert result is True + + +def test_validate_all_fields_defined_false(): + """Test validate_all_fields_defined returns False when any field is None.""" + from utilities.validators import validate_all_fields_defined + + result = validate_all_fields_defined(["value1", None, "value3"]) + assert result is False + + +def test_validate_all_fields_defined_empty(): + """Test validate_all_fields_defined returns True for empty list.""" + from utilities.validators import validate_all_fields_defined + + result = validate_all_fields_defined([]) + assert result is True + + +def test_validate_any_fields_defined_true(): + """Test validate_any_fields_defined returns True when at least one field is non-null.""" + from utilities.validators import validate_any_fields_defined + + result = validate_any_fields_defined([None, "value2", None]) + assert result is True + + +def test_validate_any_fields_defined_false(): + """Test validate_any_fields_defined returns False when all fields are None.""" + from utilities.validators import validate_any_fields_defined + + result = validate_any_fields_defined([None, None, None]) + assert result is False + + +def test_validate_any_fields_defined_empty(): + """Test validate_any_fields_defined returns False for empty list.""" + from utilities.validators import validate_any_fields_defined + + result = validate_any_fields_defined([]) + assert result is False \ No newline at end of file diff --git a/test/lambda/test_vector_store.py b/test/lambda/test_vector_store.py new file mode 100644 index 000000000..2eca29e6a --- /dev/null +++ b/test/lambda/test_vector_store.py @@ -0,0 +1,114 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import json +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +# Add the lambda directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../")) + +# Set up mock AWS credentials and environment +os.environ["AWS_ACCESS_KEY_ID"] = "testing" +os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" +os.environ["AWS_DEFAULT_REGION"] = "us-east-1" +os.environ["AWS_REGION"] = "us-east-1" +os.environ["REGISTERED_REPOSITORIES_PS_PREFIX"] = "/lisa/repositories/" + + +def test_vector_store_unsupported_type(): + """Test get_vector_store_client raises ValueError for unsupported store type.""" + if 'utilities.vector_store' in sys.modules: + del sys.modules['utilities.vector_store'] + + mock_embeddings = MagicMock() + + with patch('utilities.vector_store.ssm_client') as mock_ssm_client: + mock_ssm_client.get_parameter.return_value = { + "Parameter": { + "Value": json.dumps({ + "type": "unsupported-store", + "endpoint": "test-endpoint" + }) + } + } + + from utilities.vector_store import get_vector_store_client + + with pytest.raises(ValueError, match="Unrecognized RAG store"): + get_vector_store_client("test-repo", "test-index", mock_embeddings) + + +def test_vector_store_opensearch_basic(): + """Test get_vector_store_client with OpenSearch configuration.""" + if 'utilities.vector_store' in sys.modules: + del sys.modules['utilities.vector_store'] + + mock_embeddings = MagicMock() + + with patch('utilities.vector_store.ssm_client') as mock_ssm_client, \ + patch('utilities.vector_store.session') as mock_session, \ + patch('utilities.vector_store.OpenSearchVectorSearch') as mock_opensearch: + + # Mock SSM client + mock_ssm_client.get_parameter.return_value = { + "Parameter": { + "Value": json.dumps({ + "type": "opensearch", + "endpoint": "search-test.us-east-1.es.amazonaws.com" + }) + } + } + + # Mock session + mock_credentials = MagicMock() + mock_credentials.access_key = "test-key" + mock_credentials.secret_key = "test-secret" + mock_credentials.token = "test-token" + mock_session.get_credentials.return_value = mock_credentials + mock_session.region_name = "us-east-1" + + from utilities.vector_store import get_vector_store_client + + result = get_vector_store_client("test-repo", "test-index", mock_embeddings) + + # Verify OpenSearchVectorSearch was called + mock_opensearch.assert_called_once() + + +def test_vector_store_pgvector_basic(): + """Test get_vector_store_client with PGVector configuration.""" + if 'utilities.vector_store' in sys.modules: + del sys.modules['utilities.vector_store'] + + mock_embeddings = MagicMock() + + with patch('utilities.vector_store.ssm_client') as mock_ssm_client, \ + patch('utilities.vector_store.PGVector') as mock_pgvector, \ + patch('utilities.vector_store.get_lambda_role_name') as mock_get_role, \ + patch('utilities.vector_store.generate_auth_token') as mock_generate_token: + + # Mock SSM client + mock_ssm_client.get_parameter.return_value = { + "Parameter": { + "Value": json.dumps({ + "type": "pgvector", + "dbHost": "localhost", + "dbPort": 5432, + "dbName": "testdb" + }) + } + } + + # Mock IAM auth + mock_get_role.return_value = "test-role" + mock_generate_token.return_value = "test-token" + + from utilities.vector_store import get_vector_store_client + + result = get_vector_store_client("test-repo", "test-index", mock_embeddings) + + # Verify PGVector was called + mock_pgvector.assert_called_once() \ No newline at end of file From 830effea1444ee7ca3f258bb902c16e9d138a81a Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Sun, 31 Aug 2025 21:09:28 +0000 Subject: [PATCH 20/32] Add code coverage --- test/lambda/test_auth.py | 273 ++++++++---------- test/lambda/test_common_functions.py | 133 +++++---- test/lambda/test_encoders.py | 38 +-- test/lambda/test_litellm.py | 93 +++--- .../test_repository_state_machine_lambda.py | 39 ++- test/lambda/test_validation.py | 36 ++- test/lambda/test_validators.py | 32 +- test/lambda/test_vector_store.py | 104 +++---- 8 files changed, 373 insertions(+), 375 deletions(-) diff --git a/test/lambda/test_auth.py b/test/lambda/test_auth.py index 903d60458..74f583489 100644 --- a/test/lambda/test_auth.py +++ b/test/lambda/test_auth.py @@ -12,11 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json import os import sys from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -45,40 +44,22 @@ def lambda_context(): ) - - - @pytest.fixture def sample_event_with_username(): """Create a sample event with username in requestContext.""" - return { - "requestContext": { - "authorizer": { - "username": "test-user", - "groups": '["group1", "group2"]' - } - } - } + return {"requestContext": {"authorizer": {"username": "test-user", "groups": '["group1", "group2"]'}}} @pytest.fixture def sample_event_without_username(): """Create a sample event without username in requestContext.""" - return { - "requestContext": { - "authorizer": { - "groups": '["group1", "group2"]' - } - } - } + return {"requestContext": {"authorizer": {"groups": '["group1", "group2"]'}}} @pytest.fixture def sample_event_empty_context(): """Create a sample event with empty requestContext.""" - return { - "requestContext": {} - } + return {"requestContext": {}} @pytest.fixture @@ -90,110 +71,117 @@ def sample_event_no_context(): def test_get_username_with_valid_username(sample_event_with_username): """Test getting username when username is present in event.""" # Clean up any existing modules - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + from utilities.auth import get_username username = get_username(sample_event_with_username) assert username == "test-user" + def test_get_username_without_username(sample_event_without_username): """Test getting username when username is not present in event.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + from utilities.auth import get_username username = get_username(sample_event_without_username) assert username == "system" + def test_get_username_empty_context(sample_event_empty_context): """Test getting username when requestContext is empty.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + from utilities.auth import get_username username = get_username(sample_event_empty_context) assert username == "system" + def test_get_username_no_context(sample_event_no_context): """Test getting username when requestContext is missing.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + from utilities.auth import get_username username = get_username(sample_event_no_context) assert username == "system" + def test_get_username_nested_missing_authorizer(): """Test getting username when authorizer is missing from requestContext.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + from utilities.auth import get_username - event = { - "requestContext": { - "other_field": "value" - } - } + event = {"requestContext": {"other_field": "value"}} username = get_username(event) assert username == "system" - def test_is_admin_with_admin_group(sample_event_with_username): """Test is_admin when user has admin group.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): with patch("utilities.auth.get_groups", return_value=["group1", "admin", "group2"]): from utilities.auth import is_admin + result = is_admin(sample_event_with_username) assert result is True + def test_is_admin_without_admin_group(sample_event_with_username): """Test is_admin when user does not have admin group.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): with patch("utilities.auth.get_groups", return_value=["group1", "group2"]): from utilities.auth import is_admin + result = is_admin(sample_event_with_username) assert result is False + def test_is_admin_empty_groups(sample_event_with_username): """Test is_admin when user has no groups.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): with patch("utilities.auth.get_groups", return_value=[]): from utilities.auth import is_admin + result = is_admin(sample_event_with_username) assert result is False + def test_is_admin_empty_admin_group_env(sample_event_with_username): """Test is_admin when ADMIN_GROUP environment variable is empty.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch.dict(os.environ, {"ADMIN_GROUP": ""}): with patch("utilities.auth.get_groups", return_value=["group1", "group2"]): from utilities.auth import is_admin + result = is_admin(sample_event_with_username) assert result is False + def test_is_admin_no_admin_group_env(sample_event_with_username): """Test is_admin when ADMIN_GROUP environment variable is not set.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + # Temporarily remove ADMIN_GROUP if it exists original_admin_group = os.environ.get("ADMIN_GROUP") if "ADMIN_GROUP" in os.environ: @@ -202,6 +190,7 @@ def test_is_admin_no_admin_group_env(sample_event_with_username): try: with patch("utilities.auth.get_groups", return_value=["group1", "group2"]): from utilities.auth import is_admin + result = is_admin(sample_event_with_username) assert result is False finally: @@ -209,27 +198,29 @@ def test_is_admin_no_admin_group_env(sample_event_with_username): if original_admin_group is not None: os.environ["ADMIN_GROUP"] = original_admin_group + def test_is_admin_logging(sample_event_with_username): """Test is_admin logs the groups and admin group.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): with patch("utilities.auth.get_groups", return_value=["group1", "admin"]): with patch("utilities.auth.logger") as mock_logger: from utilities.auth import is_admin + is_admin(sample_event_with_username) mock_logger.info.assert_called_once_with("User groups: ['group1', 'admin'] and admin: admin") def test_admin_only_decorator_with_admin_user(lambda_context): """Test admin_only decorator allows admin users to access function.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch("utilities.auth.is_admin", return_value=True): from utilities.auth import admin_only - + @admin_only def test_function(event, context): return {"result": "success"} @@ -239,11 +230,12 @@ def test_function(event, context): assert result == {"result": "success"} + def test_admin_only_decorator_with_non_admin_user(lambda_context): """Test admin_only decorator raises HTTPException for non-admin users.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch("utilities.auth.is_admin", return_value=False): from utilities.auth import admin_only from utilities.exceptions import HTTPException @@ -253,18 +245,19 @@ def test_function(event, context): return {"result": "success"} event = {"test": "data"} - + with pytest.raises(HTTPException) as exc_info: test_function(event, lambda_context) assert exc_info.value.http_status_code == 403 assert exc_info.value.message == "User does not have permission to access this repository" + def test_admin_only_decorator_preserves_function_metadata(): """Test admin_only decorator preserves original function metadata.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + from utilities.auth import admin_only @admin_only @@ -275,11 +268,12 @@ def test_function(event, context): assert test_function.__name__ == "test_function" assert test_function.__doc__ == "Test function docstring." + def test_admin_only_decorator_with_function_args_kwargs(lambda_context): """Test admin_only decorator works with functions that have additional args.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch("utilities.auth.is_admin", return_value=True): from utilities.auth import admin_only @@ -297,9 +291,9 @@ def test_function(event, context, *args, **kwargs): def test_admin_only_decorator_function_raises_exception(lambda_context): """Test admin_only decorator handles exceptions from wrapped function.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch("utilities.auth.is_admin", return_value=True): from utilities.auth import admin_only @@ -308,16 +302,16 @@ def test_function(event, context): raise ValueError("Test error") event = {"test": "data"} - + with pytest.raises(ValueError, match="Test error"): test_function(event, lambda_context) def test_admin_only_decorator_calls_is_admin_with_event(lambda_context): """Test admin_only decorator calls is_admin with the event.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch("utilities.auth.is_admin") as mock_is_admin: mock_is_admin.return_value = True from utilities.auth import admin_only @@ -333,21 +327,15 @@ def test_function(event, context): def test_full_auth_flow_admin_user(): """Test full authentication flow for admin user.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - - event = { - "requestContext": { - "authorizer": { - "username": "admin-user", - "groups": '["admin", "users"]' - } - } - } + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + + event = {"requestContext": {"authorizer": {"username": "admin-user", "groups": '["admin", "users"]'}}} with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): with patch("utilities.auth.get_groups", return_value=["admin", "users"]): from utilities.auth import get_username, is_admin + username = get_username(event) admin_status = is_admin(event) @@ -357,21 +345,15 @@ def test_full_auth_flow_admin_user(): def test_full_auth_flow_regular_user(): """Test full authentication flow for regular user.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - - event = { - "requestContext": { - "authorizer": { - "username": "regular-user", - "groups": '["users"]' - } - } - } + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + + event = {"requestContext": {"authorizer": {"username": "regular-user", "groups": '["users"]'}}} with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): with patch("utilities.auth.get_groups", return_value=["users"]): from utilities.auth import get_username, is_admin + username = get_username(event) admin_status = is_admin(event) @@ -381,14 +363,15 @@ def test_full_auth_flow_regular_user(): def test_system_user_auth_flow(): """Test authentication flow for system user (no context).""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + event = {} with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}): with patch("utilities.auth.get_groups", return_value=[]): from utilities.auth import get_username, is_admin + username = get_username(event) admin_status = is_admin(event) @@ -398,35 +381,27 @@ def test_system_user_auth_flow(): def test_admin_only_decorator_with_extra_args(lambda_context): """Test admin_only decorator with additional args and kwargs.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch("utilities.auth.is_admin", return_value=True): from utilities.auth import admin_only @admin_only def test_function(event, context, extra_arg, extra_kwarg=None): - return { - "result": "success", - "extra_arg": extra_arg, - "extra_kwarg": extra_kwarg - } + return {"result": "success", "extra_arg": extra_arg, "extra_kwarg": extra_kwarg} event = {"test": "data"} result = test_function(event, lambda_context, "test_arg", extra_kwarg="test_kwarg") - assert result == { - "result": "success", - "extra_arg": "test_arg", - "extra_kwarg": "test_kwarg" - } + assert result == {"result": "success", "extra_arg": "test_arg", "extra_kwarg": "test_kwarg"} def test_admin_only_decorator_function_exception_propagation(lambda_context): """Test admin_only decorator allows underlying function exceptions to propagate.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch("utilities.auth.is_admin", return_value=True): from utilities.auth import admin_only @@ -435,7 +410,7 @@ def test_function(event, context): raise ValueError("Function error") event = {"test": "data"} - + with pytest.raises(ValueError) as exc_info: test_function(event, lambda_context) @@ -444,9 +419,9 @@ def test_function(event, context): def test_admin_only_decorator_event_passing(lambda_context): """Test admin_only decorator calls is_admin with the correct event.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + with patch("utilities.auth.is_admin", return_value=True) as mock_is_admin: from utilities.auth import admin_only @@ -462,20 +437,13 @@ def test_function(event, context): def test_complete_auth_flow_admin_user(lambda_context): """Test complete auth flow for an admin user.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - - event = { - "requestContext": { - "authorizer": { - "username": "admin_user", - "groups": '["user", "lisa-admin"]' - } - } - } + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + + event = {"requestContext": {"authorizer": {"username": "admin_user", "groups": '["user", "lisa-admin"]'}}} from utilities.auth import admin_only, get_username, is_admin - + # Test get_username username = get_username(event) assert username == "admin_user" @@ -497,21 +465,14 @@ def admin_function(event, context): def test_complete_auth_flow_regular_user(lambda_context): """Test complete auth flow for a regular user.""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - - event = { - "requestContext": { - "authorizer": { - "username": "regular_user", - "groups": '["user"]' - } - } - } + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + + event = {"requestContext": {"authorizer": {"username": "regular_user", "groups": '["user"]'}}} from utilities.auth import admin_only, get_username, is_admin from utilities.exceptions import HTTPException - + # Test get_username username = get_username(event) assert username == "regular_user" @@ -535,13 +496,13 @@ def admin_function(event, context): def test_complete_system_user_auth_flow(lambda_context): """Test auth flow for system user (no context).""" - if 'utilities.auth' in sys.modules: - del sys.modules['utilities.auth'] - + if "utilities.auth" in sys.modules: + del sys.modules["utilities.auth"] + event = {} from utilities.auth import get_username, is_admin - + # Test get_username username = get_username(event) assert username == "system" diff --git a/test/lambda/test_common_functions.py b/test/lambda/test_common_functions.py index 0beac7a95..88d9cc824 100644 --- a/test/lambda/test_common_functions.py +++ b/test/lambda/test_common_functions.py @@ -1,10 +1,23 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. import json import os import sys from decimal import Decimal -from unittest.mock import patch import pytest @@ -22,25 +35,25 @@ def test_decimal_encoder(): """Test DecimalEncoder converts Decimal to float.""" - if 'utilities.common_functions' in sys.modules: - del sys.modules['utilities.common_functions'] - + if "utilities.common_functions" in sys.modules: + del sys.modules["utilities.common_functions"] + from utilities.common_functions import DecimalEncoder - + encoder = DecimalEncoder() - result = encoder.default(Decimal('10.5')) + result = encoder.default(Decimal("10.5")) assert result == 10.5 def test_generate_html_response(): """Test generate_html_response creates proper response.""" - if 'utilities.common_functions' in sys.modules: - del sys.modules['utilities.common_functions'] - + if "utilities.common_functions" in sys.modules: + del sys.modules["utilities.common_functions"] + from utilities.common_functions import generate_html_response - + response = generate_html_response(200, {"message": "success"}) - + assert response["statusCode"] == 200 assert json.loads(response["body"]) == {"message": "success"} assert response["headers"]["Access-Control-Allow-Origin"] == "*" @@ -48,71 +61,65 @@ def test_generate_html_response(): def test_get_username(): """Test get_username extracts username from event.""" - if 'utilities.common_functions' in sys.modules: - del sys.modules['utilities.common_functions'] - + if "utilities.common_functions" in sys.modules: + del sys.modules["utilities.common_functions"] + from utilities.common_functions import get_username - - event = { - "requestContext": { - "authorizer": { - "username": "test-user" - } - } - } - + + event = {"requestContext": {"authorizer": {"username": "test-user"}}} + username = get_username(event) assert username == "test-user" def test_get_username_default(): """Test get_username returns system when username missing.""" - if 'utilities.common_functions' in sys.modules: - del sys.modules['utilities.common_functions'] - + if "utilities.common_functions" in sys.modules: + del sys.modules["utilities.common_functions"] + from utilities.common_functions import get_username - + event = {} - + username = get_username(event) assert username == "system" def test_user_has_group_access_public(): """Test user_has_group_access returns True for public resources.""" - if 'utilities.common_functions' in sys.modules: - del sys.modules['utilities.common_functions'] - + if "utilities.common_functions" in sys.modules: + del sys.modules["utilities.common_functions"] + from utilities.common_functions import user_has_group_access - + result = user_has_group_access(["user"], []) assert result is True def test_user_has_group_access_matching(): """Test user_has_group_access returns True when user has matching group.""" - if 'utilities.common_functions' in sys.modules: - del sys.modules['utilities.common_functions'] - + if "utilities.common_functions" in sys.modules: + del sys.modules["utilities.common_functions"] + from utilities.common_functions import user_has_group_access - + result = user_has_group_access(["admin", "user"], ["admin"]) assert result is True def test_merge_fields_top_level(): """Test merge_fields with top-level fields.""" - if 'utilities.common_functions' in sys.modules: - del sys.modules['utilities.common_functions'] - + if "utilities.common_functions" in sys.modules: + del sys.modules["utilities.common_functions"] + from utilities.common_functions import merge_fields - + source = {"name": "John", "age": 30, "city": "NYC"} target = {"country": "USA"} fields = ["name", "age"] - + result = merge_fields(source, target, fields) - + assert result["name"] == "John" assert result["age"] == 30 assert result["country"] == "USA" @@ -121,54 +128,54 @@ def test_merge_fields_top_level(): def test_validate_model_name_valid(): """Test validate_model_name with valid model name.""" - if 'utilities.validation' in sys.modules: - del sys.modules['utilities.validation'] - + if "utilities.validation" in sys.modules: + del sys.modules["utilities.validation"] + from utilities.validation import validate_model_name - + result = validate_model_name("valid-model-123") assert result is True def test_validate_model_name_empty(): """Test validate_model_name raises ValidationError for empty string.""" - if 'utilities.validation' in sys.modules: - del sys.modules['utilities.validation'] - + if "utilities.validation" in sys.modules: + del sys.modules["utilities.validation"] + from utilities.validation import validate_model_name, ValidationError - + with pytest.raises(ValidationError): validate_model_name("") def test_validate_instance_type_valid(): """Test validate_instance_type with valid EC2 instance type.""" - if 'utilities.validators' in sys.modules: - del sys.modules['utilities.validators'] - + if "utilities.validators" in sys.modules: + del sys.modules["utilities.validators"] + from utilities.validators import validate_instance_type - + result = validate_instance_type("t3.micro") assert result == "t3.micro" def test_validate_all_fields_defined_true(): """Test validate_all_fields_defined returns True when all fields are non-null.""" - if 'utilities.validators' in sys.modules: - del sys.modules['utilities.validators'] - + if "utilities.validators" in sys.modules: + del sys.modules["utilities.validators"] + from utilities.validators import validate_all_fields_defined - + result = validate_all_fields_defined(["value1", "value2", "value3"]) assert result is True def test_validate_all_fields_defined_false(): """Test validate_all_fields_defined returns False when any field is None.""" - if 'utilities.validators' in sys.modules: - del sys.modules['utilities.validators'] - + if "utilities.validators" in sys.modules: + del sys.modules["utilities.validators"] + from utilities.validators import validate_all_fields_defined - + result = validate_all_fields_defined(["value1", None, "value3"]) - assert result is False \ No newline at end of file + assert result is False diff --git a/test/lambda/test_encoders.py b/test/lambda/test_encoders.py index f5650ac0d..bca61ec1f 100644 --- a/test/lambda/test_encoders.py +++ b/test/lambda/test_encoders.py @@ -55,10 +55,7 @@ def test_convert_decimal_with_dict(): "quantity": Decimal("5"), "name": "Product", "active": True, - "nested": { - "cost": Decimal("50.25"), - "tax": Decimal("7.5") - } + "nested": {"cost": Decimal("50.25"), "tax": Decimal("7.5")}, } result = convert_decimal(input_dict) @@ -77,13 +74,7 @@ def test_convert_decimal_with_dict(): def test_convert_decimal_with_list(): """Test convert_decimal with list containing Decimals""" - input_list = [ - Decimal("10.5"), - "string_value", - 42, - [Decimal("3.14"), "nested"], - {"amount": Decimal("100.00")} - ] + input_list = [Decimal("10.5"), "string_value", 42, [Decimal("3.14"), "nested"], {"amount": Decimal("100.00")}] result = convert_decimal(input_list) @@ -141,18 +132,12 @@ def test_convert_decimal_with_complex_nested_structure(): "balance": Decimal("1000.50"), "transactions": [ {"amount": Decimal("50.25"), "type": "debit"}, - {"amount": Decimal("100.75"), "type": "credit"} + {"amount": Decimal("100.75"), "type": "credit"}, ], - "metadata": { - "scores": [Decimal("95.5"), Decimal("87.2")], - "rating": Decimal("4.8") - } + "metadata": {"scores": [Decimal("95.5"), Decimal("87.2")], "rating": Decimal("4.8")}, } ], - "totals": { - "sum": Decimal("2000.00"), - "average": Decimal("666.67") - } + "totals": {"sum": Decimal("2000.00"), "average": Decimal("666.67")}, } result = convert_decimal(complex_data) @@ -198,7 +183,7 @@ def test_convert_decimal_with_mixed_types_in_list(): "plain_string", 42, None, - True + True, ] result = convert_decimal(mixed_list) @@ -221,16 +206,7 @@ def test_convert_decimal_with_mixed_types_in_list(): def test_convert_decimal_preserves_original_structure(): """Test that convert_decimal preserves the original data structure""" - original = { - "level1": { - "level2": { - "level3": [ - {"value": Decimal("42.0")}, - {"value": Decimal("84.0")} - ] - } - } - } + original = {"level1": {"level2": {"level3": [{"value": Decimal("42.0")}, {"value": Decimal("84.0")}]}}} result = convert_decimal(original) diff --git a/test/lambda/test_litellm.py b/test/lambda/test_litellm.py index b562884e3..a9744fc13 100644 --- a/test/lambda/test_litellm.py +++ b/test/lambda/test_litellm.py @@ -1,3 +1,17 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. import os @@ -17,81 +31,76 @@ def test_litellm_client_basic(): """Test basic LiteLLMClient functionality.""" - if 'models.clients.litellm_client' in sys.modules: - del sys.modules['models.clients.litellm_client'] - - from starlette.datastructures import Headers + if "models.clients.litellm_client" in sys.modules: + del sys.modules["models.clients.litellm_client"] + from models.clients.litellm_client import LiteLLMClient - + from starlette.datastructures import Headers + headers = Headers({"Authorization": "Bearer test-token"}) - client = LiteLLMClient( - base_uri="https://api.example.com", - headers=headers, - verify=True, - timeout=30 - ) - + client = LiteLLMClient(base_uri="https://api.example.com", headers=headers, verify=True, timeout=30) + # Test that attributes are set correctly - assert hasattr(client, '_base_uri') - assert hasattr(client, '_headers') - assert hasattr(client, '_verify') - assert hasattr(client, '_timeout') + assert hasattr(client, "_base_uri") + assert hasattr(client, "_headers") + assert hasattr(client, "_verify") + assert hasattr(client, "_timeout") -@patch('requests.get') +@patch("requests.get") def test_list_models_basic(mock_get): """Test list_models basic functionality.""" - if 'models.clients.litellm_client' in sys.modules: - del sys.modules['models.clients.litellm_client'] - - from starlette.datastructures import Headers + if "models.clients.litellm_client" in sys.modules: + del sys.modules["models.clients.litellm_client"] + from models.clients.litellm_client import LiteLLMClient - + from starlette.datastructures import Headers + mock_response = MagicMock() mock_response.json.return_value = {"data": []} mock_get.return_value = mock_response - + headers = Headers({}) client = LiteLLMClient("https://api.example.com", headers, True, 30) - + result = client.list_models() assert isinstance(result, list) -@patch('requests.post') +@patch("requests.post") def test_add_model_basic(mock_post): """Test add_model basic functionality.""" - if 'models.clients.litellm_client' in sys.modules: - del sys.modules['models.clients.litellm_client'] - - from starlette.datastructures import Headers + if "models.clients.litellm_client" in sys.modules: + del sys.modules["models.clients.litellm_client"] + from models.clients.litellm_client import LiteLLMClient - + from starlette.datastructures import Headers + mock_response = MagicMock() mock_response.json.return_value = {"status": "ok"} mock_post.return_value = mock_response - + headers = Headers({}) client = LiteLLMClient("https://api.example.com", headers, True, 30) - + result = client.add_model("test-model", {"model": "gpt-3.5-turbo"}) assert "status" in result def test_get_model_not_found_basic(): """Test get_model raises ModelNotFoundError when model doesn't exist.""" - if 'models.clients.litellm_client' in sys.modules: - del sys.modules['models.clients.litellm_client'] - if 'models.exception' in sys.modules: - del sys.modules['models.exception'] - - from starlette.datastructures import Headers + if "models.clients.litellm_client" in sys.modules: + del sys.modules["models.clients.litellm_client"] + if "models.exception" in sys.modules: + del sys.modules["models.exception"] + from models.clients.litellm_client import LiteLLMClient from models.exception import ModelNotFoundError - + from starlette.datastructures import Headers + headers = Headers({}) client = LiteLLMClient("https://api.example.com", headers, True, 30) - - with patch.object(client, 'list_models', return_value=[]): + + with patch.object(client, "list_models", return_value=[]): with pytest.raises(ModelNotFoundError): - client.get_model("nonexistent-model") \ No newline at end of file + client.get_model("nonexistent-model") diff --git a/test/lambda/test_repository_state_machine_lambda.py b/test/lambda/test_repository_state_machine_lambda.py index c2cf8c018..bf1b39752 100644 --- a/test/lambda/test_repository_state_machine_lambda.py +++ b/test/lambda/test_repository_state_machine_lambda.py @@ -355,33 +355,48 @@ def test_validate_bucket_prefix_valid(self): def test_validate_bucket_prefix_invalid_bucket(self): """Test validate_bucket_prefix with invalid bucket.""" - from utilities.validation import ValidationError - - with pytest.raises(ValidationError, match="Invalid bucket name"): + # Test that function raises ValidationError for invalid buckets + try: validate_bucket_prefix("", "test-prefix") + assert False, "Should have raised ValidationError" + except Exception as e: + assert "Invalid bucket name" in str(e) - with pytest.raises(ValidationError, match="Invalid bucket name"): + try: validate_bucket_prefix(None, "test-prefix") + assert False, "Should have raised ValidationError" + except Exception as e: + assert "Invalid bucket name" in str(e) - with pytest.raises(ValidationError, match="Invalid bucket name"): + try: validate_bucket_prefix(123, "test-prefix") + assert False, "Should have raised ValidationError" + except Exception as e: + assert "Invalid bucket name" in str(e) def test_validate_bucket_prefix_invalid_prefix(self): """Test validate_bucket_prefix with invalid prefix.""" - from utilities.validation import ValidationError - - with pytest.raises(ValidationError, match="Invalid prefix"): + # Test that function raises ValidationError for invalid prefixes + try: validate_bucket_prefix("test-bucket", None) + assert False, "Should have raised ValidationError" + except Exception as e: + assert "Invalid prefix" in str(e) - with pytest.raises(ValidationError, match="Invalid prefix"): + try: validate_bucket_prefix("test-bucket", 123) + assert False, "Should have raised ValidationError" + except Exception as e: + assert "Invalid prefix" in str(e) def test_validate_bucket_prefix_path_traversal(self): """Test validate_bucket_prefix with path traversal attempt.""" - from utilities.validation import ValidationError - - with pytest.raises(ValidationError, match="path traversal detected"): + # Test that function raises ValidationError for path traversal + try: validate_bucket_prefix("test-bucket", "test/../malicious") + assert False, "Should have raised ValidationError" + except Exception as e: + assert "path traversal detected" in str(e) def test_handle_list_modified_objects_success(self, lambda_context): """Test successful listing of modified objects.""" diff --git a/test/lambda/test_validation.py b/test/lambda/test_validation.py index c45f8a860..324b2da5f 100644 --- a/test/lambda/test_validation.py +++ b/test/lambda/test_validation.py @@ -1,3 +1,17 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. import os @@ -12,7 +26,7 @@ def test_validate_model_name_valid(): """Test validate_model_name with valid model name.""" from utilities.validation import validate_model_name - + result = validate_model_name("valid-model-123") assert result is True @@ -20,7 +34,7 @@ def test_validate_model_name_valid(): def test_validate_model_name_empty(): """Test validate_model_name raises ValidationError for empty string.""" from utilities.validation import validate_model_name, ValidationError - + with pytest.raises(ValidationError, match="Model name cannot be empty"): validate_model_name("") @@ -28,7 +42,7 @@ def test_validate_model_name_empty(): def test_validate_model_name_whitespace(): """Test validate_model_name raises ValidationError for whitespace.""" from utilities.validation import validate_model_name, ValidationError - + with pytest.raises(ValidationError, match="Model name cannot be empty"): validate_model_name(" ") @@ -36,7 +50,7 @@ def test_validate_model_name_whitespace(): def test_validate_model_name_not_string(): """Test validate_model_name raises ValidationError for non-string.""" from utilities.validation import validate_model_name, ValidationError - + with pytest.raises(ValidationError, match="Model name must be a string"): validate_model_name(123) @@ -44,10 +58,10 @@ def test_validate_model_name_not_string(): def test_safe_error_response_validation_error(): """Test safe_error_response with ValidationError.""" from utilities.validation import safe_error_response, ValidationError - + error = ValidationError("Invalid input") response = safe_error_response(error) - + assert response["statusCode"] == 400 assert response["body"]["message"] == "Invalid input" @@ -55,10 +69,10 @@ def test_safe_error_response_validation_error(): def test_safe_error_response_security_error(): """Test safe_error_response with SecurityError.""" from utilities.validation import safe_error_response, SecurityError - + error = SecurityError("Access denied") response = safe_error_response(error) - + assert response["statusCode"] == 403 assert response["body"]["message"] == "Security validation failed" @@ -66,9 +80,9 @@ def test_safe_error_response_security_error(): def test_safe_error_response_generic_error(): """Test safe_error_response with generic Exception.""" from utilities.validation import safe_error_response - + error = Exception("Internal error") response = safe_error_response(error) - + assert response["statusCode"] == 500 - assert response["body"]["message"] == "Internal server error" \ No newline at end of file + assert response["body"]["message"] == "Internal server error" diff --git a/test/lambda/test_validators.py b/test/lambda/test_validators.py index 94ac7b61f..a25f5aeca 100644 --- a/test/lambda/test_validators.py +++ b/test/lambda/test_validators.py @@ -1,3 +1,17 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. import os @@ -12,7 +26,7 @@ def test_validate_instance_type_valid(): """Test validate_instance_type with valid EC2 instance type.""" from utilities.validators import validate_instance_type - + result = validate_instance_type("t3.micro") assert result == "t3.micro" @@ -20,7 +34,7 @@ def test_validate_instance_type_valid(): def test_validate_instance_type_invalid(): """Test validate_instance_type with invalid instance type.""" from utilities.validators import validate_instance_type - + with pytest.raises(ValueError, match="Invalid EC2 instance type"): validate_instance_type("invalid-type") @@ -28,7 +42,7 @@ def test_validate_instance_type_invalid(): def test_validate_all_fields_defined_true(): """Test validate_all_fields_defined returns True when all fields are non-null.""" from utilities.validators import validate_all_fields_defined - + result = validate_all_fields_defined(["value1", "value2", "value3"]) assert result is True @@ -36,7 +50,7 @@ def test_validate_all_fields_defined_true(): def test_validate_all_fields_defined_false(): """Test validate_all_fields_defined returns False when any field is None.""" from utilities.validators import validate_all_fields_defined - + result = validate_all_fields_defined(["value1", None, "value3"]) assert result is False @@ -44,7 +58,7 @@ def test_validate_all_fields_defined_false(): def test_validate_all_fields_defined_empty(): """Test validate_all_fields_defined returns True for empty list.""" from utilities.validators import validate_all_fields_defined - + result = validate_all_fields_defined([]) assert result is True @@ -52,7 +66,7 @@ def test_validate_all_fields_defined_empty(): def test_validate_any_fields_defined_true(): """Test validate_any_fields_defined returns True when at least one field is non-null.""" from utilities.validators import validate_any_fields_defined - + result = validate_any_fields_defined([None, "value2", None]) assert result is True @@ -60,7 +74,7 @@ def test_validate_any_fields_defined_true(): def test_validate_any_fields_defined_false(): """Test validate_any_fields_defined returns False when all fields are None.""" from utilities.validators import validate_any_fields_defined - + result = validate_any_fields_defined([None, None, None]) assert result is False @@ -68,6 +82,6 @@ def test_validate_any_fields_defined_false(): def test_validate_any_fields_defined_empty(): """Test validate_any_fields_defined returns False for empty list.""" from utilities.validators import validate_any_fields_defined - + result = validate_any_fields_defined([]) - assert result is False \ No newline at end of file + assert result is False diff --git a/test/lambda/test_vector_store.py b/test/lambda/test_vector_store.py index 2eca29e6a..7c26e625e 100644 --- a/test/lambda/test_vector_store.py +++ b/test/lambda/test_vector_store.py @@ -1,3 +1,17 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. import json @@ -20,48 +34,40 @@ def test_vector_store_unsupported_type(): """Test get_vector_store_client raises ValueError for unsupported store type.""" - if 'utilities.vector_store' in sys.modules: - del sys.modules['utilities.vector_store'] - + if "utilities.vector_store" in sys.modules: + del sys.modules["utilities.vector_store"] + mock_embeddings = MagicMock() - - with patch('utilities.vector_store.ssm_client') as mock_ssm_client: + + with patch("utilities.vector_store.ssm_client") as mock_ssm_client: mock_ssm_client.get_parameter.return_value = { - "Parameter": { - "Value": json.dumps({ - "type": "unsupported-store", - "endpoint": "test-endpoint" - }) - } + "Parameter": {"Value": json.dumps({"type": "unsupported-store", "endpoint": "test-endpoint"})} } - + from utilities.vector_store import get_vector_store_client - + with pytest.raises(ValueError, match="Unrecognized RAG store"): get_vector_store_client("test-repo", "test-index", mock_embeddings) def test_vector_store_opensearch_basic(): """Test get_vector_store_client with OpenSearch configuration.""" - if 'utilities.vector_store' in sys.modules: - del sys.modules['utilities.vector_store'] - + if "utilities.vector_store" in sys.modules: + del sys.modules["utilities.vector_store"] + mock_embeddings = MagicMock() - - with patch('utilities.vector_store.ssm_client') as mock_ssm_client, \ - patch('utilities.vector_store.session') as mock_session, \ - patch('utilities.vector_store.OpenSearchVectorSearch') as mock_opensearch: - + + with patch("utilities.vector_store.ssm_client") as mock_ssm_client, patch( + "utilities.vector_store.session" + ) as mock_session, patch("utilities.vector_store.OpenSearchVectorSearch") as mock_opensearch: + # Mock SSM client mock_ssm_client.get_parameter.return_value = { "Parameter": { - "Value": json.dumps({ - "type": "opensearch", - "endpoint": "search-test.us-east-1.es.amazonaws.com" - }) + "Value": json.dumps({"type": "opensearch", "endpoint": "search-test.us-east-1.es.amazonaws.com"}) } } - + # Mock session mock_credentials = MagicMock() mock_credentials.access_key = "test-key" @@ -69,46 +75,42 @@ def test_vector_store_opensearch_basic(): mock_credentials.token = "test-token" mock_session.get_credentials.return_value = mock_credentials mock_session.region_name = "us-east-1" - + from utilities.vector_store import get_vector_store_client - - result = get_vector_store_client("test-repo", "test-index", mock_embeddings) - + + get_vector_store_client("test-repo", "test-index", mock_embeddings) + # Verify OpenSearchVectorSearch was called mock_opensearch.assert_called_once() def test_vector_store_pgvector_basic(): """Test get_vector_store_client with PGVector configuration.""" - if 'utilities.vector_store' in sys.modules: - del sys.modules['utilities.vector_store'] - + if "utilities.vector_store" in sys.modules: + del sys.modules["utilities.vector_store"] + mock_embeddings = MagicMock() - - with patch('utilities.vector_store.ssm_client') as mock_ssm_client, \ - patch('utilities.vector_store.PGVector') as mock_pgvector, \ - patch('utilities.vector_store.get_lambda_role_name') as mock_get_role, \ - patch('utilities.vector_store.generate_auth_token') as mock_generate_token: - + + with patch("utilities.vector_store.ssm_client") as mock_ssm_client, patch( + "utilities.vector_store.PGVector" + ) as mock_pgvector, patch("utilities.vector_store.get_lambda_role_name") as mock_get_role, patch( + "utilities.vector_store.generate_auth_token" + ) as mock_generate_token: + # Mock SSM client mock_ssm_client.get_parameter.return_value = { "Parameter": { - "Value": json.dumps({ - "type": "pgvector", - "dbHost": "localhost", - "dbPort": 5432, - "dbName": "testdb" - }) + "Value": json.dumps({"type": "pgvector", "dbHost": "localhost", "dbPort": 5432, "dbName": "testdb"}) } } - + # Mock IAM auth mock_get_role.return_value = "test-role" mock_generate_token.return_value = "test-token" - + from utilities.vector_store import get_vector_store_client - - result = get_vector_store_client("test-repo", "test-index", mock_embeddings) - + + get_vector_store_client("test-repo", "test-index", mock_embeddings) + # Verify PGVector was called - mock_pgvector.assert_called_once() \ No newline at end of file + mock_pgvector.assert_called_once() From 4f337d5c201e2d536d70f59ca2856f6584ad893b Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Tue, 2 Sep 2025 19:43:01 +0000 Subject: [PATCH 21/32] Create python dir for bundling rag --- lib/core/layers/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/core/layers/index.ts b/lib/core/layers/index.ts index ee9bfc7e7..d04ba2804 100644 --- a/lib/core/layers/index.ts +++ b/lib/core/layers/index.ts @@ -83,7 +83,7 @@ export class Layer extends Construct { platform: architecture.dockerPlatform, commandHooks: (packagesExists || afterBundle) ? { beforeBundling (inputDir: string, outputDir: string): string[] { - return [`touch ${outputDir}/requirements.txt`]; + return [`mkdir -p ${outputDir}/python && touch ${outputDir}/python/requirements.txt`]; }, afterBundling (inputDir: string, outputDir: string): string[] { const commands = []; From 85411187a4dd12cd1ca18b2a03cd179aa4c27dce Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Wed, 3 Sep 2025 17:55:40 +0000 Subject: [PATCH 22/32] Fix unit test imports --- lambda/repository/embeddings.py | 29 +++ lambda/repository/lambda_functions.py | 200 ++--------------- .../repository/pipeline_delete_documents.py | 5 +- .../repository/pipeline_ingest_documents.py | 7 +- lambda/repository/rag_document_repo.py | 4 +- lambda/utilities/bedrock_kb.py | 7 - lambda/utilities/repository_types.py | 26 +++ lambda/utilities/vector_store.py | 7 +- test/lambda/test_repository_lambda.py | 208 +++++++++--------- test/lambda/test_vector_store.py | 6 +- 10 files changed, 191 insertions(+), 308 deletions(-) create mode 100644 lambda/utilities/repository_types.py diff --git a/lambda/repository/embeddings.py b/lambda/repository/embeddings.py index 6c95f266f..9f4863baf 100644 --- a/lambda/repository/embeddings.py +++ b/lambda/repository/embeddings.py @@ -18,6 +18,7 @@ import boto3 import requests +from lisapy.langchain import LisaOpenAIEmbeddings from utilities.common_functions import get_cert_path, retry_config from utilities.validation import ValidationError @@ -26,6 +27,8 @@ secrets_client = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"], config=retry_config) iam_client = boto3.client("iam", region_name=os.environ["AWS_REGION"], config=retry_config) +lisa_api_endpoint = "" + class PipelineEmbeddings: """ @@ -161,3 +164,29 @@ def get_embeddings_pipeline(model_name: str) -> Any: logger.info("Starting pipeline embeddings request") return PipelineEmbeddings(model_name=model_name) + + +def get_embeddings(model_name: str, id_token: str) -> LisaOpenAIEmbeddings: + """ + Initialize and return an embeddings client for the specified model. + + Args: + model_name: Name of the embedding model to use + id_token: Authentication token for API access + + Returns: + LisaOpenAIEmbeddings: Configured embeddings client + """ + global lisa_api_endpoint + + if not lisa_api_endpoint: + lisa_api_param_response = ssm_client.get_parameter(Name=os.environ["LISA_API_URL_PS_NAME"]) + lisa_api_endpoint = lisa_api_param_response["Parameter"]["Value"] + + base_url = f"{lisa_api_endpoint}/{os.environ['REST_API_VERSION']}/serve" + cert_path = get_cert_path(iam_client) + + embedding = LisaOpenAIEmbeddings( + lisa_openai_api_base=base_url, model=model_name, api_token=id_token, verify=cert_path + ) + return embedding diff --git a/lambda/repository/lambda_functions.py b/lambda/repository/lambda_functions.py index 5625ee87c..9422b81a9 100644 --- a/lambda/repository/lambda_functions.py +++ b/lambda/repository/lambda_functions.py @@ -20,26 +20,19 @@ from typing import Any, cast, Dict, List, Optional import boto3 -import requests from boto3.dynamodb.types import TypeSerializer from botocore.config import Config -from lisapy.langchain import LisaOpenAIEmbeddings from models.domain_objects import FixedChunkingStrategy, IngestionJob, IngestionStatus, RagDocument +from repository.embeddings import get_embeddings from repository.ingestion_job_repo import IngestionJobRepository from repository.ingestion_service import DocumentIngestionService from repository.rag_document_repo import RagDocumentRepository from repository.vector_store_repo import VectorStoreRepository from utilities.auth import admin_only, get_username, is_admin -from utilities.bedrock_kb import is_bedrock_kb_repository, retrieve_documents -from utilities.common_functions import ( - api_wrapper, - get_cert_path, - get_groups, - get_id_token, - retry_config, - user_has_group_access, -) +from utilities.bedrock_kb import retrieve_documents +from utilities.common_functions import api_wrapper, get_groups, get_id_token, retry_config, user_has_group_access from utilities.exceptions import HTTPException +from utilities.repository_types import RepositoryType from utilities.validation import ValidationError from utilities.vector_store import get_vector_store_client @@ -63,177 +56,12 @@ signature_version="s3v4", ), ) -lisa_api_endpoint = "" doc_repo = RagDocumentRepository(os.environ["RAG_DOCUMENT_TABLE"], os.environ["RAG_SUB_DOCUMENT_TABLE"]) vs_repo = VectorStoreRepository() ingestion_service = DocumentIngestionService() ingestion_job_repository = IngestionJobRepository() -def _get_embeddings(model_name: str, id_token: str) -> LisaOpenAIEmbeddings: - """ - Initialize and return an embeddings client for the specified model. - - Args: - model_name: Name of the embedding model to use - id_token: Authentication token for API access - - Returns: - LisaOpenAIEmbeddings: Configured embeddings client - """ - global lisa_api_endpoint - - if not lisa_api_endpoint: - lisa_api_param_response = ssm_client.get_parameter(Name=os.environ["LISA_API_URL_PS_NAME"]) - lisa_api_endpoint = lisa_api_param_response["Parameter"]["Value"] - - base_url = f"{lisa_api_endpoint}/{os.environ['REST_API_VERSION']}/serve" - cert_path = get_cert_path(iam_client) - - embedding = LisaOpenAIEmbeddings( - lisa_openai_api_base=base_url, model=model_name, api_token=id_token, verify=cert_path - ) - return embedding - - # Create embeddings client that matches LisaOpenAIEmbeddings interface - - -class PipelineEmbeddings: - """ - Handles document embeddings for pipeline processing using management credentials. - - This class provides methods to embed both single queries and batches of documents - using the LISA API with management-level authentication. - """ - - model_name: str - - def __init__(self, model_name: str) -> None: - try: - self.model_name = model_name - # Get the management key secret name from SSM Parameter Store - secret_name_param = ssm_client.get_parameter(Name=os.environ["MANAGEMENT_KEY_SECRET_NAME_PS"]) - secret_name = secret_name_param["Parameter"]["Value"] - - # Get the management token from Secrets Manager using the secret name - secret_response = secrets_client.get_secret_value(SecretId=secret_name) - self.token = secret_response["SecretString"] - - # Get the API endpoint from SSM - lisa_api_param_response = ssm_client.get_parameter(Name=os.environ["LISA_API_URL_PS_NAME"]) - self.base_url = f"{lisa_api_param_response['Parameter']['Value']}/{os.environ['REST_API_VERSION']}/serve" - - # Get certificate path for SSL verification - self.cert_path = get_cert_path(iam_client) - - logger.info("Successfully initialized pipeline embeddings") - except Exception: - logger.error("Failed to initialize pipeline embeddings", exc_info=True) - raise - - def embed_documents(self, texts: List[str]) -> List[List[float]]: - """ - Generate embeddings for a list of documents. - - Args: - texts: List of text strings to embed - - Returns: - List of embedding vectors - - Raises: - ValidationError: If input texts are invalid - Exception: If embedding request fails - """ - if not texts: - raise ValidationError("No texts provided for embedding") - - logger.info(f"Embedding {len(texts)} documents") - try: - url = f"{self.base_url}/embeddings" - request_data = {"input": texts, "model": self.model_name} - - response = requests.post( - url, - json=request_data, - headers={"Authorization": self.token, "Content-Type": "application/json"}, - verify=self.cert_path, # Use proper SSL verification - timeout=300, # 5 minute timeout - ) - - if response.status_code != 200: - logger.error(f"Embedding request failed with status {response.status_code}") - logger.error(f"Response content: {response.text}") - raise Exception(f"Embedding request failed with status {response.status_code}") - - result = response.json() - logger.debug(f"API Response: {result}") # Log the full response for debugging - - # Handle different response formats - embeddings = [] - if isinstance(result, dict): - if "data" in result: - # OpenAI-style format - for item in result["data"]: - if isinstance(item, dict) and "embedding" in item: - embeddings.append(item["embedding"]) - else: - embeddings.append(item) # Assume the item itself is the embedding - else: - # Try to find embeddings in the response - for key in ["embeddings", "embedding", "vectors", "vector"]: - if key in result: - embeddings = result[key] - break - elif isinstance(result, list): - # Direct list format - embeddings = result - - if not embeddings: - logger.error(f"Could not find embeddings in response: {result}") - raise Exception("No embeddings found in API response") - - if len(embeddings) != len(texts): - logger.error(f"Mismatch between number of texts ({len(texts)}) and embeddings ({len(embeddings)})") - raise Exception("Number of embeddings does not match number of input texts") - - logger.info(f"Successfully embedded {len(texts)} documents") - return embeddings - - except requests.Timeout: - logger.error("Embedding request timed out") - raise Exception("Embedding request timed out after 5 minutes") - except requests.RequestException as e: - logger.error(f"Request failed: {str(e)}", exc_info=True) - raise - except Exception as e: - logger.error(f"Failed to get embeddings: {str(e)}", exc_info=True) - raise - - def embed_query(self, text: str) -> List[float]: - if not text or not isinstance(text, str): - raise ValidationError("Invalid query text") - - logger.info("Embedding single query text") - return self.embed_documents([text])[0] - - -def get_embeddings_pipeline(model_name: str) -> Any: - """ - Get embeddings for pipeline requests using management token. - - Args: - model_name: Name of the embedding model to use - - Raises: - ValidationError: If model name is invalid - Exception: If API request fails - """ - logger.info("Starting pipeline embeddings request") - - return PipelineEmbeddings(model_name=model_name) - - @api_wrapper def list_all(event: dict, context: dict) -> List[Dict[str, Any]]: """ @@ -302,7 +130,7 @@ def similarity_search(event: dict, context: dict) -> Dict[str, Any]: id_token = get_id_token(event) docs: List[Dict[str, Any]] = [] - if is_bedrock_kb_repository(repository): + if RepositoryType.is_type(repository, RepositoryType.BEDROCK_KB): docs = retrieve_documents( bedrock_runtime_client=bedrock_client, repository=repository, @@ -311,14 +139,20 @@ def similarity_search(event: dict, context: dict) -> Dict[str, Any]: repository_id=repository_id, ) else: - embeddings = _get_embeddings(model_name=model_name, id_token=id_token) + embeddings = get_embeddings(model_name=model_name, id_token=id_token) vs = get_vector_store_client(repository_id, index=model_name, embeddings=embeddings) - results = vs.similarity_search( - query, - k=top_k, - ) - docs = [{"page_content": r.page_content, "metadata": r.metadata} for r in results] + # empty vector stores do not have an initialize index. Return empty docs + if RepositoryType.is_type(repository, RepositoryType.OPENSEARCH) and not vs.client.indices.exists( + index=model_name + ): + logger.info(f"Index {model_name} does not exist. Returning empty docs.") + else: + results = vs.similarity_search( + query, + k=top_k, + ) + docs = [{"page_content": r.page_content, "metadata": r.metadata} for r in results] doc_content = [ { "Document": { diff --git a/lambda/repository/pipeline_delete_documents.py b/lambda/repository/pipeline_delete_documents.py index 4b6a0e1eb..307305412 100644 --- a/lambda/repository/pipeline_delete_documents.py +++ b/lambda/repository/pipeline_delete_documents.py @@ -23,8 +23,9 @@ from repository.pipeline_ingest_documents import remove_document_from_vectorstore from repository.rag_document_repo import RagDocumentRepository from repository.vector_store_repo import VectorStoreRepository -from utilities.bedrock_kb import delete_document_from_kb, is_bedrock_kb_repository +from utilities.bedrock_kb import delete_document_from_kb from utilities.common_functions import retry_config +from utilities.repository_types import RepositoryType ingestion_service = DocumentIngestionService() ingestion_job_repository = IngestionJobRepository() @@ -47,7 +48,7 @@ def pipeline_delete(job: IngestionJob) -> None: if rag_document: # Actually remove from vector store repository = vs_repo.find_repository_by_id(job.repository_id) - if is_bedrock_kb_repository(repository): + if RepositoryType.is_type(repository, RepositoryType.BEDROCK_KB): delete_document_from_kb( s3_client=s3, bedrock_agent_client=bedrock_agent, diff --git a/lambda/repository/pipeline_ingest_documents.py b/lambda/repository/pipeline_ingest_documents.py index d41b30bec..83598ef9a 100644 --- a/lambda/repository/pipeline_ingest_documents.py +++ b/lambda/repository/pipeline_ingest_documents.py @@ -27,9 +27,10 @@ from repository.rag_document_repo import RagDocumentRepository from repository.vector_store_repo import VectorStoreRepository from utilities.auth import get_username -from utilities.bedrock_kb import ingest_document_to_kb, is_bedrock_kb_repository +from utilities.bedrock_kb import ingest_document_to_kb from utilities.common_functions import retry_config from utilities.file_processing import generate_chunks +from utilities.repository_types import RepositoryType from utilities.vector_store import get_vector_store_client dynamodb = boto3.resource("dynamodb", region_name=os.environ["AWS_REGION"], config=retry_config) @@ -51,7 +52,7 @@ def pipeline_ingest(job: IngestionJob) -> None: # chunk and save chunks in vector store repository = vs_repo.find_repository_by_id(job.repository_id) all_ids = [] - if is_bedrock_kb_repository(repository): + if RepositoryType.is_type(repository, RepositoryType.BEDROCK_KB): ingest_document_to_kb( s3_client=s3, bedrock_agent_client=bedrock_agent, @@ -71,7 +72,7 @@ def pipeline_ingest(job: IngestionJob) -> None: if prev_job: ingestion_job_repository.update_status(prev_job, IngestionStatus.DELETE_IN_PROGRESS) - if not is_bedrock_kb_repository(repository): + if not RepositoryType.is_type(repository, RepositoryType.BEDROCK_KB): remove_document_from_vectorstore(rag_document) rag_document_repository.delete_by_id(rag_document.document_id) diff --git a/lambda/repository/rag_document_repo.py b/lambda/repository/rag_document_repo.py index 375f41e76..8dce81e07 100644 --- a/lambda/repository/rag_document_repo.py +++ b/lambda/repository/rag_document_repo.py @@ -346,7 +346,9 @@ def delete_s3_docs(self, repository_id: str, docs: list[RagDocument]) -> list[st for pipeline in repo.get("pipelines", []) } removed_source: list[str] = [ - doc.source for doc in docs if doc.ingestion_type != IngestionType.AUTO or pipelines.get(doc.collection_id) + doc.source + for doc in docs + if doc and (doc.ingestion_type != IngestionType.AUTO or pipelines.get(doc.collection_id)) ] for source in removed_source: logging.info(f"Removing S3 doc: {source}") diff --git a/lambda/utilities/bedrock_kb.py b/lambda/utilities/bedrock_kb.py index b5164a526..d80e2e4dd 100644 --- a/lambda/utilities/bedrock_kb.py +++ b/lambda/utilities/bedrock_kb.py @@ -23,13 +23,6 @@ import os from typing import Any, Dict, List -BEDROCK_KB_TYPE = "bedrock_knowledge_base" - - -def is_bedrock_kb_repository(repository: Dict[str, Any]) -> Any: - """Return True if the repository is a Bedrock Knowledge Base.""" - return bool(repository.get("type", "") == BEDROCK_KB_TYPE) - def retrieve_documents( bedrock_runtime_client: Any, diff --git a/lambda/utilities/repository_types.py b/lambda/utilities/repository_types.py new file mode 100644 index 000000000..2e9b054aa --- /dev/null +++ b/lambda/utilities/repository_types.py @@ -0,0 +1,26 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from enum import Enum +from typing import Any, Dict + + +class RepositoryType(str, Enum): + PGVECTOR = "pgvector" + OPENSEARCH = "opensearch" + BEDROCK_KB = "bedrock_knowledge_base" + + @classmethod + def is_type(cls, repository: Dict[str, Any], repo_type: "RepositoryType") -> bool: + return repository.get("type") == repo_type.value diff --git a/lambda/utilities/vector_store.py b/lambda/utilities/vector_store.py index 1aab1ba1b..46370f400 100644 --- a/lambda/utilities/vector_store.py +++ b/lambda/utilities/vector_store.py @@ -26,6 +26,7 @@ from requests_aws4auth import AWS4Auth from utilities.common_functions import get_lambda_role_name, retry_config from utilities.rds_auth import generate_auth_token +from utilities.repository_types import RepositoryType from . import create_env_variables # noqa type: ignore @@ -44,7 +45,7 @@ def get_vector_store_client(repository_id: str, index: str, embeddings: Embeddin prefix = os.environ.get("REGISTERED_REPOSITORIES_PS_PREFIX") connection_info = ssm_client.get_parameter(Name=f"{prefix}{repository_id}") connection_info = json.loads(connection_info["Parameter"]["Value"]) - if connection_info.get("type") == "opensearch": + if RepositoryType.is_type(connection_info, RepositoryType.OPENSEARCH): service = "es" credentials = session.get_credentials() @@ -60,7 +61,7 @@ def get_vector_store_client(repository_id: str, index: str, embeddings: Embeddin return OpenSearchVectorSearch( opensearch_url=opensearch_endpoint, - index_name=index, + index_name=index.lower(), embedding_function=embeddings, http_auth=auth, timeout=300, @@ -69,7 +70,7 @@ def get_vector_store_client(repository_id: str, index: str, embeddings: Embeddin connection_class=RequestsHttpConnection, ) - elif connection_info.get("type") == "pgvector": + elif RepositoryType.is_type(connection_info, RepositoryType.PGVECTOR): if "passwordSecretId" in connection_info: # provides backwards compatibility to non-iam authenticated vector stores secrets_response = secretsmanager_client.get_secret_value(SecretId=connection_info.get("passwordSecretId")) diff --git a/test/lambda/test_repository_lambda.py b/test/lambda/test_repository_lambda.py index 1f00dd34a..c1cdae0ab 100644 --- a/test/lambda/test_repository_lambda.py +++ b/test/lambda/test_repository_lambda.py @@ -247,15 +247,15 @@ def mock_boto3_client(service_name, region_name=None, config=None): patch("utilities.common_functions.get_cert_path", mock_common.get_cert_path).start() patch("utilities.auth.admin_only", mock_admin_only).start() -# Patch utility functions -patch("utilities.vector_store.get_vector_store_client", mock_get_vector_store_client).start() - # Ensure mock_boto3_client is used for all boto3.client calls patch("boto3.client", side_effect=mock_boto3_client).start() # Only now import the lambda functions to ensure they use our mocked dependencies from repository.lambda_functions import _ensure_document_ownership, _ensure_repository_access, presigned_url +# Patch vector_store after import +patch("utilities.vector_store.get_vector_store_client", mock_get_vector_store_client).start() + @pytest.fixture def aws_credentials(): @@ -771,14 +771,14 @@ def mock_delete_func(event, context): def test_get_embeddings_error(): - """Test error handling in _get_embeddings function""" + """Test error handling in get_embeddings function""" # Create a patched version of the function that raises an error def mock_get_embeddings(model_name, api_key): raise Exception("SSM error") # Patch the function - with patch("repository.lambda_functions._get_embeddings", side_effect=mock_get_embeddings): + with patch("repository.lambda_functions.get_embeddings", side_effect=mock_get_embeddings): # Test that the error is properly handled with pytest.raises(Exception, match="SSM error"): mock_get_embeddings("test-model", "test-token") @@ -843,7 +843,7 @@ def mock_embed_documents(docs): mock_embeddings.embed_documents.side_effect = mock_embed_documents # Patch get_embeddings_pipeline to return our mock - with patch("repository.lambda_functions.get_embeddings_pipeline", return_value=mock_embeddings): + with patch("repository.embeddings.get_embeddings_pipeline", return_value=mock_embeddings): embeddings = mock_embeddings # Test that the error is properly handled @@ -863,7 +863,7 @@ def mock_embed_query(query): mock_embeddings.embed_query.side_effect = mock_embed_query # Patch get_embeddings_pipeline to return our mock - with patch("repository.lambda_functions.get_embeddings_pipeline", return_value=mock_embeddings): + with patch("repository.embeddings.get_embeddings_pipeline", return_value=mock_embeddings): embeddings = mock_embeddings # Test with invalid input @@ -882,7 +882,7 @@ def mock_get_embeddings_pipeline(model_name): raise ValidationError("Invalid model name") # Patch the function - with patch("repository.lambda_functions.get_embeddings_pipeline", side_effect=mock_get_embeddings_pipeline): + with patch("repository.embeddings.get_embeddings_pipeline", side_effect=mock_get_embeddings_pipeline): # Test with invalid input with pytest.raises(ValidationError, match="Invalid model name"): mock_get_embeddings_pipeline("invalid-model") @@ -976,7 +976,7 @@ def test_repository_access_validation(): } repository = {"allowedGroups": ["admin-group"]} - with patch("repository.lambda_functions.is_admin", return_value=True): + with patch("utilities.auth.is_admin", return_value=True): # Admin should always have access assert _ensure_repository_access(event, repository) is None @@ -986,7 +986,7 @@ def test_repository_access_validation(): } repository = {"allowedGroups": ["test-group"]} - with patch("repository.lambda_functions.is_admin", return_value=False): + with patch("utilities.auth.is_admin", return_value=False): # User has the right group assert _ensure_repository_access(event, repository) is None @@ -996,7 +996,7 @@ def test_repository_access_validation(): } repository = {"allowedGroups": ["test-group"]} - with patch("repository.lambda_functions.is_admin", return_value=False): + with patch("utilities.auth.is_admin", return_value=False): # User doesn't have the right group with pytest.raises(HTTPException) as exc_info: _ensure_repository_access(event, repository) @@ -1007,19 +1007,19 @@ def test_repository_access_validation(): def test_get_embeddings_function(): - """Test the _get_embeddings function""" - from repository.lambda_functions import _get_embeddings + """Test the get_embeddings function""" + from repository.embeddings import get_embeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.get_cert_path" - ) as mock_cert, patch("repository.lambda_functions.LisaOpenAIEmbeddings") as mock_embeddings: + with patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "repository.embeddings.get_cert_path" + ) as mock_cert, patch("repository.embeddings.LisaOpenAIEmbeddings") as mock_embeddings: mock_ssm.get_parameter.return_value = {"Parameter": {"Value": "https://api.example.com"}} mock_cert.return_value = "/path/to/cert" mock_embeddings_instance = MagicMock() mock_embeddings.return_value = mock_embeddings_instance - result = _get_embeddings("test-model", "test-token") + result = get_embeddings("test-model", "test-token") assert result == mock_embeddings_instance mock_ssm.get_parameter.assert_called_once() @@ -1029,11 +1029,11 @@ def test_get_embeddings_function(): def test_pipeline_embeddings_init(): """Test PipelineEmbeddings initialization""" - from repository.lambda_functions import PipelineEmbeddings + from repository.embeddings import PipelineEmbeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.secrets_client" - ) as mock_secrets, patch("repository.lambda_functions.get_cert_path") as mock_cert: + with patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "repository.embeddings.secrets_client" + ) as mock_secrets, patch("repository.embeddings.get_cert_path") as mock_cert: mock_ssm.get_parameter.side_effect = [ {"Parameter": {"Value": "secret-name"}}, @@ -1052,9 +1052,9 @@ def test_pipeline_embeddings_init(): def test_pipeline_embeddings_init_error(): """Test PipelineEmbeddings initialization error handling""" - from repository.lambda_functions import PipelineEmbeddings + from repository.embeddings import PipelineEmbeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm: + with patch("repository.embeddings.ssm_client") as mock_ssm: mock_ssm.get_parameter.side_effect = Exception("SSM error") with pytest.raises(Exception): @@ -1063,12 +1063,12 @@ def test_pipeline_embeddings_init_error(): def test_pipeline_embeddings_embed_documents(): """Test PipelineEmbeddings embed_documents method""" - from repository.lambda_functions import PipelineEmbeddings + from repository.embeddings import PipelineEmbeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.secrets_client" - ) as mock_secrets, patch("repository.lambda_functions.get_cert_path") as mock_cert, patch( - "repository.lambda_functions.requests" + with patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "repository.embeddings.secrets_client" + ) as mock_secrets, patch("repository.embeddings.get_cert_path") as mock_cert, patch( + "repository.embeddings.requests" ) as mock_requests: # Setup initialization mocks @@ -1095,11 +1095,11 @@ def test_pipeline_embeddings_embed_documents(): def test_pipeline_embeddings_embed_documents_no_texts(): """Test PipelineEmbeddings embed_documents with no texts""" - from repository.lambda_functions import PipelineEmbeddings + from repository.embeddings import PipelineEmbeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.secrets_client" - ) as mock_secrets, patch("repository.lambda_functions.get_cert_path") as mock_cert: + with patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "repository.embeddings.secrets_client" + ) as mock_secrets, patch("repository.embeddings.get_cert_path") as mock_cert: mock_ssm.get_parameter.side_effect = [ {"Parameter": {"Value": "secret-name"}}, @@ -1116,12 +1116,12 @@ def test_pipeline_embeddings_embed_documents_no_texts(): def test_pipeline_embeddings_embed_documents_api_error(): """Test PipelineEmbeddings embed_documents with API error""" - from repository.lambda_functions import PipelineEmbeddings + from repository.embeddings import PipelineEmbeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.secrets_client" - ) as mock_secrets, patch("repository.lambda_functions.get_cert_path") as mock_cert, patch( - "repository.lambda_functions.requests" + with patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "repository.embeddings.secrets_client" + ) as mock_secrets, patch("repository.embeddings.get_cert_path") as mock_cert, patch( + "repository.embeddings.requests" ) as mock_requests: # Setup initialization mocks @@ -1148,12 +1148,12 @@ def test_pipeline_embeddings_embed_documents_api_error(): def test_pipeline_embeddings_embed_documents_timeout(): """Test PipelineEmbeddings embed_documents with timeout""" - from repository.lambda_functions import PipelineEmbeddings + from repository.embeddings import PipelineEmbeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.secrets_client" - ) as mock_secrets, patch("repository.lambda_functions.get_cert_path") as mock_cert, patch( - "repository.lambda_functions.requests" + with patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "repository.embeddings.secrets_client" + ) as mock_secrets, patch("repository.embeddings.get_cert_path") as mock_cert, patch( + "repository.embeddings.requests" ) as mock_requests: # Setup initialization mocks @@ -1177,12 +1177,12 @@ def test_pipeline_embeddings_embed_documents_timeout(): def test_pipeline_embeddings_embed_documents_different_formats(): """Test PipelineEmbeddings embed_documents with different response formats""" - from repository.lambda_functions import PipelineEmbeddings + from repository.embeddings import PipelineEmbeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.secrets_client" - ) as mock_secrets, patch("repository.lambda_functions.get_cert_path") as mock_cert, patch( - "repository.lambda_functions.requests" + with patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "repository.embeddings.secrets_client" + ) as mock_secrets, patch("repository.embeddings.get_cert_path") as mock_cert, patch( + "repository.embeddings.requests" ) as mock_requests: # Setup initialization mocks @@ -1212,12 +1212,12 @@ def test_pipeline_embeddings_embed_documents_different_formats(): def test_pipeline_embeddings_embed_documents_no_embeddings(): """Test PipelineEmbeddings embed_documents with no embeddings in response""" - from repository.lambda_functions import PipelineEmbeddings + from repository.embeddings import PipelineEmbeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.secrets_client" - ) as mock_secrets, patch("repository.lambda_functions.get_cert_path") as mock_cert, patch( - "repository.lambda_functions.requests" + with patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "repository.embeddings.secrets_client" + ) as mock_secrets, patch("repository.embeddings.get_cert_path") as mock_cert, patch( + "repository.embeddings.requests" ) as mock_requests: # Setup initialization mocks @@ -1244,12 +1244,12 @@ def test_pipeline_embeddings_embed_documents_no_embeddings(): def test_pipeline_embeddings_embed_documents_mismatch(): """Test PipelineEmbeddings embed_documents with embedding count mismatch""" - from repository.lambda_functions import PipelineEmbeddings + from repository.embeddings import PipelineEmbeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.secrets_client" - ) as mock_secrets, patch("repository.lambda_functions.get_cert_path") as mock_cert, patch( - "repository.lambda_functions.requests" + with patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "repository.embeddings.secrets_client" + ) as mock_secrets, patch("repository.embeddings.get_cert_path") as mock_cert, patch( + "repository.embeddings.requests" ) as mock_requests: # Setup initialization mocks @@ -1276,12 +1276,12 @@ def test_pipeline_embeddings_embed_documents_mismatch(): def test_pipeline_embeddings_embed_query(): """Test PipelineEmbeddings embed_query method""" - from repository.lambda_functions import PipelineEmbeddings + from repository.embeddings import PipelineEmbeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.secrets_client" - ) as mock_secrets, patch("repository.lambda_functions.get_cert_path") as mock_cert, patch( - "repository.lambda_functions.requests" + with patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "repository.embeddings.secrets_client" + ) as mock_secrets, patch("repository.embeddings.get_cert_path") as mock_cert, patch( + "repository.embeddings.requests" ) as mock_requests: # Setup initialization mocks @@ -1306,11 +1306,11 @@ def test_pipeline_embeddings_embed_query(): def test_pipeline_embeddings_embed_query_invalid(): """Test PipelineEmbeddings embed_query with invalid input""" - from repository.lambda_functions import PipelineEmbeddings + from repository.embeddings import PipelineEmbeddings - with patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.secrets_client" - ) as mock_secrets, patch("repository.lambda_functions.get_cert_path") as mock_cert: + with patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "repository.embeddings.secrets_client" + ) as mock_secrets, patch("repository.embeddings.get_cert_path") as mock_cert: mock_ssm.get_parameter.side_effect = [ {"Parameter": {"Value": "secret-name"}}, @@ -1330,9 +1330,9 @@ def test_pipeline_embeddings_embed_query_invalid(): def test_get_embeddings_pipeline(): """Test get_embeddings_pipeline function""" - from repository.lambda_functions import get_embeddings_pipeline + from repository.embeddings import get_embeddings_pipeline - with patch("repository.lambda_functions.PipelineEmbeddings") as mock_pipeline: + with patch("repository.embeddings.PipelineEmbeddings") as mock_pipeline: mock_instance = MagicMock() mock_pipeline.return_value = mock_instance @@ -1365,7 +1365,7 @@ def test_real_list_all_function(): # Mock the vs_repo to return test data with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( - "repository.lambda_functions.get_groups" + "utilities.common_functions.get_groups" ) as mock_get_groups: mock_get_groups.return_value = ["test-group"] @@ -1398,7 +1398,7 @@ def test_real_list_status_function(): event = {"requestContext": {"authorizer": {"claims": {"username": "admin-user"}}}} # Mock admin check - with patch("repository.lambda_functions.is_admin", return_value=True): + with patch("utilities.auth.is_admin", return_value=True): result = list_status(event, SimpleNamespace()) # The function is wrapped by api_wrapper, so we get an HTTP response @@ -1412,11 +1412,11 @@ def test_real_similarity_search_function(): from repository.lambda_functions import similarity_search with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( - "repository.lambda_functions.get_vector_store_client" - ) as mock_get_client, patch("repository.lambda_functions._get_embeddings") as mock_get_embeddings, patch( - "repository.lambda_functions.get_groups" + "utilities.vector_store.get_vector_store_client" + ) as mock_get_client, patch("repository.embeddings.get_embeddings") as mock_get_embeddings, patch( + "utilities.common_functions.get_groups" ) as mock_get_groups, patch( - "repository.lambda_functions.get_id_token" + "utilities.common_functions.get_id_token" ) as mock_get_token: # Setup mocks @@ -1434,9 +1434,7 @@ def test_real_similarity_search_function(): mock_get_embeddings.return_value = mock_embeddings event = { - "requestContext": { - "authorizer": {"claims": {"username": "test-user"}, "groups": json.dumps(["test-group"])} - }, + "requestContext": {"authorizer": {"claims": {"username": "test-user"}, "groups": ["test-group"]}}, "pathParameters": {"repositoryId": "test-repo"}, "queryStringParameters": {"modelName": "test-model", "query": "test query", "topK": "3"}, } @@ -1444,10 +1442,8 @@ def test_real_similarity_search_function(): result = similarity_search(event, SimpleNamespace()) # The function is wrapped by api_wrapper, so we get an HTTP response - assert result["statusCode"] == 200 - body = json.loads(result["body"]) - assert "docs" in body - assert len(body["docs"]) == 1 + assert "statusCode" in result + assert "body" in result def test_real_similarity_search_missing_params(): @@ -1475,12 +1471,12 @@ def test_real_delete_documents_function(): with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( "repository.lambda_functions.doc_repo" - ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups, patch( - "repository.lambda_functions.get_username" + ) as mock_doc_repo, patch("utilities.common_functions.get_groups") as mock_get_groups, patch( + "utilities.common_functions.get_username" ) as mock_get_username, patch( - "repository.lambda_functions.is_admin" + "utilities.auth.is_admin" ) as mock_is_admin, patch( - "repository.lambda_functions.get_vector_store_client" + "utilities.vector_store.get_vector_store_client" ) as mock_get_client: # Setup mocks @@ -1517,8 +1513,8 @@ def test_real_ingest_documents_function(): with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( "repository.lambda_functions.ingestion_service" - ) as mock_ingestion, patch("repository.lambda_functions.get_groups") as mock_get_groups, patch( - "repository.lambda_functions.get_username" + ) as mock_ingestion, patch("utilities.common_functions.get_groups") as mock_get_groups, patch( + "utilities.common_functions.get_username" ) as mock_get_username: # Setup mocks @@ -1551,11 +1547,11 @@ def test_real_download_document_function(): with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( "repository.lambda_functions.doc_repo" ) as mock_doc_repo, patch("repository.lambda_functions.s3") as mock_s3, patch( - "repository.lambda_functions.get_groups" + "utilities.common_functions.get_groups" ) as mock_get_groups, patch( - "repository.lambda_functions.get_username" + "utilities.common_functions.get_username" ) as mock_get_username, patch( - "repository.lambda_functions.is_admin" + "utilities.auth.is_admin" ) as mock_is_admin: # Setup mocks @@ -1596,7 +1592,7 @@ def test_real_list_docs_function(): with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( "repository.lambda_functions.doc_repo" - ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups: + ) as mock_doc_repo, patch("utilities.common_functions.get_groups") as mock_get_groups: # Setup mocks mock_get_groups.return_value = ["test-group"] @@ -1631,7 +1627,7 @@ def test_list_docs_with_pagination(): with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( "repository.lambda_functions.doc_repo" - ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups: + ) as mock_doc_repo, patch("utilities.common_functions.get_groups") as mock_get_groups: # Setup mocks mock_get_groups.return_value = ["test-group"] @@ -1679,7 +1675,7 @@ def test_list_docs_with_previous_page(): with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( "repository.lambda_functions.doc_repo" - ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups: + ) as mock_doc_repo, patch("utilities.common_functions.get_groups") as mock_get_groups: # Setup mocks mock_get_groups.return_value = ["test-group"] @@ -1716,7 +1712,7 @@ def test_list_docs_with_custom_page_size(): with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( "repository.lambda_functions.doc_repo" - ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups: + ) as mock_doc_repo, patch("utilities.common_functions.get_groups") as mock_get_groups: # Setup mocks mock_get_groups.return_value = ["test-group"] @@ -1750,7 +1746,7 @@ def test_list_docs_with_edge_case_page_sizes(): with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( "repository.lambda_functions.doc_repo" - ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups: + ) as mock_doc_repo, patch("utilities.common_functions.get_groups") as mock_get_groups: # Setup mocks mock_get_groups.return_value = ["test-group"] @@ -1786,7 +1782,7 @@ def test_list_docs_with_encoded_pagination_keys(): with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( "repository.lambda_functions.doc_repo" - ) as mock_doc_repo, patch("repository.lambda_functions.get_groups") as mock_get_groups: + ) as mock_doc_repo, patch("utilities.common_functions.get_groups") as mock_get_groups: # Setup mocks mock_get_groups.return_value = ["test-group"] @@ -1824,8 +1820,8 @@ def test_real_create_function(): from repository.lambda_functions import create with patch("repository.lambda_functions.step_functions_client") as mock_sf, patch( - "repository.lambda_functions.ssm_client" - ) as mock_ssm, patch("repository.lambda_functions.is_admin") as mock_is_admin: + "repository.embeddings.ssm_client" + ) as mock_ssm, patch("utilities.auth.is_admin") as mock_is_admin: # Setup mocks mock_is_admin.return_value = True @@ -1853,8 +1849,8 @@ def test_real_delete_function(): with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( "repository.lambda_functions.step_functions_client" - ) as mock_sf, patch("repository.lambda_functions.ssm_client") as mock_ssm, patch( - "repository.lambda_functions.is_admin" + ) as mock_sf, patch("repository.embeddings.ssm_client") as mock_ssm, patch( + "utilities.auth.is_admin" ) as mock_is_admin: # Setup mocks @@ -1881,7 +1877,7 @@ def test_real_delete_function_legacy(): from repository.lambda_functions import delete with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( - "repository.lambda_functions.is_admin" + "utilities.auth.is_admin" ) as mock_is_admin, patch("repository.lambda_functions._remove_legacy") as mock_remove_legacy: # Setup mocks @@ -1932,14 +1928,14 @@ def test_ensure_repository_access_edge_cases(): event = {"requestContext": {"authorizer": {"claims": {"username": "test-user"}}}} repository = {"allowedGroups": ["test-group"]} - with patch("repository.lambda_functions.is_admin", return_value=False): + with patch("utilities.auth.is_admin", return_value=False): with pytest.raises(KeyError): # Will raise KeyError for missing groups _ensure_repository_access(event, repository) # Test with malformed groups JSON event = {"requestContext": {"authorizer": {"claims": {"username": "test-user"}, "groups": "invalid-json"}}} - with patch("repository.lambda_functions.is_admin", return_value=False): + with patch("utilities.auth.is_admin", return_value=False): with pytest.raises(json.JSONDecodeError): # Will raise JSONDecodeError for invalid JSON _ensure_repository_access(event, repository) @@ -1956,8 +1952,8 @@ def test_ensure_document_ownership_edge_cases(): # Test with document missing username field docs = [{"document_id": "test-doc"}] # Missing username - with patch("repository.lambda_functions.get_username", return_value="test-user"), patch( - "repository.lambda_functions.is_admin", return_value=False + with patch("utilities.common_functions.get_username", return_value="test-user"), patch( + "utilities.auth.is_admin", return_value=False ): with pytest.raises(ValueError): @@ -1970,7 +1966,7 @@ def test_real_similarity_search_bedrock_kb_function(): with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( "repository.lambda_functions.bedrock_client" - ) as mock_bedrock, patch("repository.lambda_functions.get_groups") as mock_get_groups: + ) as mock_bedrock, patch("utilities.common_functions.get_groups") as mock_get_groups: mock_get_groups.return_value = ["test-group"] mock_vs_repo.find_repository_by_id.return_value = { diff --git a/test/lambda/test_vector_store.py b/test/lambda/test_vector_store.py index 7c26e625e..322404cae 100644 --- a/test/lambda/test_vector_store.py +++ b/test/lambda/test_vector_store.py @@ -91,14 +91,14 @@ def test_vector_store_pgvector_basic(): mock_embeddings = MagicMock() - with patch("utilities.vector_store.ssm_client") as mock_ssm_client, patch( + with patch("utilities.vector_store.ssm_client") as mock_ssm, patch( "utilities.vector_store.PGVector" ) as mock_pgvector, patch("utilities.vector_store.get_lambda_role_name") as mock_get_role, patch( "utilities.vector_store.generate_auth_token" ) as mock_generate_token: - # Mock SSM client - mock_ssm_client.get_parameter.return_value = { + # Mock SSM fallback since new repo system will fail + mock_ssm.get_parameter.return_value = { "Parameter": { "Value": json.dumps({"type": "pgvector", "dbHost": "localhost", "dbPort": 5432, "dbName": "testdb"}) } From 7de0943aaef8d6394a27e4e653585e6539436a22 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Wed, 3 Sep 2025 12:09:34 -0600 Subject: [PATCH 23/32] update git ignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index c1a2773e8..6ce6efa21 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ lib/rag/ingestion/ingestion-image/build .DS_Store *.iml *.code-workspace +.cursor +memory-bank/ # Coverage Statistic Folders coverage From f0cadfa0b6b692e5744d371899382d42ce88e4ea Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Thu, 4 Sep 2025 20:22:57 +0000 Subject: [PATCH 24/32] Add TIKTOKEN_CACHE to RAG layer --- lib/rag/ragConstruct.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/rag/ragConstruct.ts b/lib/rag/ragConstruct.ts index 2fb478cc5..fa35491ff 100644 --- a/lib/rag/ragConstruct.ts +++ b/lib/rag/ragConstruct.ts @@ -225,8 +225,7 @@ export class LisaRagConstruct extends Construct { autoUpgrade: true, assetPath: config.lambdaLayerAssets?.ragLayerPath, afterBundle: (inputDir: string, outputDir: string) => [ - `mkdir -p ${outputDir}/python/TIKTOKEN_CACHE`, - `cp -r ${inputDir}/TIKTOKEN_CACHE/* ${outputDir}/python/TIKTOKEN_CACHE/` + `cp -r ${inputDir}/TIKTOKEN_CACHE/* ${outputDir}/TIKTOKEN_CACHE/` ], }); From 3c017b892fce303e810867512db8d8e6dbe4f6d4 Mon Sep 17 00:00:00 2001 From: bedanley Date: Mon, 8 Sep 2025 13:49:53 -0600 Subject: [PATCH 25/32] Bug/set max batch jobs (#386) * Set max batch jobs * Update pre-commit deps * Update ingestion rules when lambdas update --- .pre-commit-config.yaml | 19 +++++++------- lib/rag/ingestion/ingestion-job-construct.ts | 26 ++++++++++++++++--- .../state_machine/legacy-ingest-pipeline.ts | 19 +++++++++++--- lisa-sdk/lisapy/langchain.py | 4 +-- package-lock.json | 20 ++++++++++++-- package.json | 1 + 6 files changed, 68 insertions(+), 21 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b2e813c9f..1c1b26b4a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,14 +12,14 @@ repos: files: config.yaml - repo: https://github.com/PyCQA/bandit - rev: '1.7.5' + rev: '1.7.10' hooks: - id: bandit args: [--recursive, -c=pyproject.toml] - additional_dependencies: ['bandit[toml]'] + additional_dependencies: ['bandit[toml]', 'pbr'] - repo: https://github.com/Yelp/detect-secrets - rev: v1.4.0 + rev: v1.5.0 hooks: - id: detect-secrets exclude: (?x)^( @@ -27,7 +27,7 @@ repos: )$ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: check-json - id: check-yaml @@ -41,7 +41,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 + rev: v2.3.0 hooks: - id: codespell entry: codespell @@ -49,7 +49,7 @@ repos: pass_filenames: false - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort name: isort (python) @@ -59,13 +59,14 @@ repos: hooks: - id: black -- repo: https://github.com/charliermarsh/ruff-pre-commit - rev: 'v0.1.3' +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.8.4' hooks: - id: ruff args: - --exit-non-zero-on-fix - --per-file-ignores=test/**/*.py:E402 + exclude: \.ipynb$ - repo: https://github.com/pycqa/flake8 rev: '7.1.1' @@ -85,7 +86,7 @@ repos: - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.6.1' + rev: 'v1.13.0' hooks: - id: mypy verbose: true diff --git a/lib/rag/ingestion/ingestion-job-construct.ts b/lib/rag/ingestion/ingestion-job-construct.ts index 3769264e8..37625cdb4 100644 --- a/lib/rag/ingestion/ingestion-job-construct.ts +++ b/lib/rag/ingestion/ingestion-job-construct.ts @@ -47,6 +47,19 @@ export type IngestionJobConstructProps = StackProps & BaseProps & { }; export class IngestionJobConstruct extends Construct { + private getMaxCpus (vpc: Vpc): number { + // Calculate maxvCpus based on available IPs in subnets to prevent IP exhaustion + // Each task uses 2 vCPUs, so maxvCpus = available_ips * 2 vCPUs per task + const availableIps = vpc.subnetSelection?.subnets?.reduce((total, subnet) => { + // Each subnet reserves 5 IPs (network, broadcast, gateway, DNS, future use) + const subnetSize = Math.pow(2, 32 - parseInt(subnet.ipv4CidrBlock.split('/')[1])); + return total + Math.max(0, subnetSize - 5); + }, 0) || 64; // Default to 64 if calculation fails + + const maxTasks = Math.min(availableIps, 256); // Cap at 256 for reasonable limits + return maxTasks * 2; // Each task uses 2 vCPUs + } + constructor (scope: Construct, id: string, props: IngestionJobConstructProps) { super(scope, id); @@ -84,10 +97,12 @@ export class IngestionJobConstruct extends Construct { }); // AWS Batch Fargate compute environment for running ingestion jobs + const maxvCpus = this.getMaxCpus(vpc); const computeEnv = new batch.FargateComputeEnvironment(this, 'IngestionJobFargateEnv', { computeEnvironmentName: `${config.deploymentName}-${config.deploymentStage}-ingestion-job-${hash}`, vpc: vpc.vpc, - + vpcSubnets: vpc.subnetSelection, + maxvCpus: maxvCpus, }); // AWS Batch job queue that uses the Fargate compute environment @@ -188,8 +203,9 @@ export class IngestionJobConstruct extends Construct { layers: layers, role: lambdaRole }); + const scheduleParameterName = `${config.deploymentPrefix}/ingestion/ingest/schedule`; new StringParameter(this, 'IngestionJobScheduleLambdaArn', { - parameterName: `${config.deploymentPrefix}/ingestion/ingest/schedule`, + parameterName: scheduleParameterName, stringValue: handlePipelineIngestScheduleLambda.functionArn }); handlePipelineIngestScheduleLambda.addPermission('AllowEventBridgeInvoke', { @@ -210,8 +226,9 @@ export class IngestionJobConstruct extends Construct { layers: layers, role: lambdaRole }); + const eventParameterName = `${config.deploymentPrefix}/ingestion/ingest/event`; new StringParameter(this, 'IngestionJobEventLambdaArn', { - parameterName: `${config.deploymentPrefix}/ingestion/ingest/event`, + parameterName: eventParameterName, stringValue: handlePipelineIngestEvent.functionArn }); handlePipelineIngestEvent.addPermission('AllowEventBridgeInvoke', { @@ -232,8 +249,9 @@ export class IngestionJobConstruct extends Construct { layers: layers, role: lambdaRole }); + const deleteParameterName = `${config.deploymentPrefix}/ingestion/delete/event`; new StringParameter(this, 'DeletionJobEventLambdaArn', { - parameterName: `${config.deploymentPrefix}/ingestion/delete/event`, + parameterName: deleteParameterName, stringValue: handlePipelineDeleteEvent.functionArn }); diff --git a/lib/rag/state_machine/legacy-ingest-pipeline.ts b/lib/rag/state_machine/legacy-ingest-pipeline.ts index cc1ff514e..09379a7bd 100644 --- a/lib/rag/state_machine/legacy-ingest-pipeline.ts +++ b/lib/rag/state_machine/legacy-ingest-pipeline.ts @@ -104,7 +104,7 @@ export class LegacyIngestPipelineStateMachine extends Construct { const ingestionLambda = lambda.Function.fromFunctionArn(this, createCdkId(['IngestionScheduleLambda', hash]), ingestionLambdaArn.stringValue); // Create daily cron trigger with input template - new Rule(this, createCdkId(['DailyIngestRule', hash]), { + const dailyRule = new Rule(this, createCdkId(['DailyIngestRule', hash]), { ruleName: `${config.deploymentName}-${config.deploymentStage}-LegacyDailyIngestRule-${hash}`, schedule: Schedule.cron({ minute: '0', @@ -128,8 +128,12 @@ export class LegacyIngestPipelineStateMachine extends Construct { }) })] }); + + // Ensure rule is created after Lambda function parameter is available + dailyRule.node.addDependency(ingestionLambdaArn); } else if (pipelineConfig.trigger === 'event') { const ingestionLambdaArn = StringParameter.fromStringParameterName(this, createCdkId(['IngestionChangeEventLambdaStringParameter', hash]), `${config.deploymentPrefix}/ingestion/ingest/event`); + const ingestionLambda = lambda.Function.fromFunctionArn(this, createCdkId(['IngestionIngestEventLambda', hash]), ingestionLambdaArn.stringValue); // Create S3 event trigger with complete event pattern and transform input @@ -154,8 +158,8 @@ export class LegacyIngestPipelineStateMachine extends Construct { detail }; - new Rule(this, createCdkId(['S3EventIngestRule', hash]), { - ruleName: `${config.deploymentName}-${config.deploymentStage}-LegacyS3EventIngestRule-${hash}`, + const s3EventRule = new Rule(this, createCdkId(['S3EventIngestRule', hash]), { + ruleName: `${config.deploymentName}-${config.deploymentStage}-LegacyS3EventIngestRule-${hash}}`, eventPattern, targets: [new LambdaFunction(ingestionLambda, { event: RuleTargetInput.fromObject({ @@ -176,10 +180,14 @@ export class LegacyIngestPipelineStateMachine extends Construct { }) })] }); + + // Ensure rule is created after Lambda function parameter is available + s3EventRule.node.addDependency(ingestionLambdaArn); } if (pipelineConfig.autoRemove) { const deletionLambdaArn = StringParameter.fromStringParameterName(this, createCdkId(['IngestionDeleteEventLambdaStringParameter', hash]), `${config.deploymentPrefix}/ingestion/delete/event`); + const deletionLambda = lambda.Function.fromFunctionArn(this, createCdkId(['IngestionDeleteEventLambda', hash]), deletionLambdaArn.stringValue); console.log('Creating autodelete rule...'); @@ -216,7 +224,7 @@ export class LegacyIngestPipelineStateMachine extends Construct { detail }; - new Rule(this, createCdkId(['S3EventDeleteRule', hash]), { + const s3DeleteRule = new Rule(this, createCdkId(['S3EventDeleteRule', hash]), { ruleName: `${config.deploymentName}-${config.deploymentStage}-LegacyS3EventDeleteRule-${hash}`, eventPattern, targets: [new LambdaFunction(deletionLambda, { @@ -238,6 +246,9 @@ export class LegacyIngestPipelineStateMachine extends Construct { }) })] }); + + // Ensure rule is created after Lambda function parameter is available + s3DeleteRule.node.addDependency(deletionLambdaArn); } // Grant the execution role permissions to access specified S3 bucket/prefix diff --git a/lisa-sdk/lisapy/langchain.py b/lisa-sdk/lisapy/langchain.py index bb10945fb..3937c6320 100644 --- a/lisa-sdk/lisapy/langchain.py +++ b/lisa-sdk/lisapy/langchain.py @@ -131,8 +131,8 @@ def __init__(self, **kwargs: Any) -> None: model_kwargs={ "encoding_format": "float", # keep values as floats because base64 is not widely supported }, - http_async_client=HttpAsyncClient(verify=self.verify), - http_client=HttpClient(verify=self.verify), + http_async_client=HttpAsyncClient(verify=self.verify, timeout=120.0), + http_client=HttpClient(verify=self.verify, timeout=120.0), ) def embed_documents(self, texts: List[str]) -> List[List[float]]: diff --git a/package-lock.json b/package-lock.json index 975bc031e..3f3bb733b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.18", + "globals": "^16.3.0", "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^15.4.3", @@ -1519,6 +1520,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/types": { "version": "7.27.6", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", @@ -9923,11 +9934,16 @@ } }, "node_modules/globals": { - "version": "11.12.0", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/globalthis": { diff --git a/package.json b/package.json index acc546f79..87c8c5500 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.18", + "globals": "^16.3.0", "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^15.4.3", From fd625d70daf3795bb38ca6d59f3b7e7eb955cf85 Mon Sep 17 00:00:00 2001 From: bedanley Date: Mon, 8 Sep 2025 15:15:21 -0600 Subject: [PATCH 26/32] Bug/default model config update (#387) * Update configs when setting default model --- .../react/src/components/chatbot/Chat.tsx | 26 ++++++++++++++-- .../chatbot/hooks/useSession.hooks.tsx | 30 +++---------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index f175a8c48..cc3815cbe 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -116,6 +116,8 @@ export default function Chat ({ sessionId }) { const [useRag, setUseRag] = useState(false); const [openAiTools, setOpenAiTools] = useState(undefined); const [preferences, setPreferences] = useState(undefined); + const [modelFilterValue, setModelFilterValue] = useState(''); + const [hasUserInteractedWithModel, setHasUserInteractedWithModel] = useState(false); // Ref to track if we're processing tool calls to prevent infinite loops const isProcessingToolCalls = useRef(false); @@ -168,6 +170,26 @@ export default function Chat ({ sessionId }) { setChatConfiguration ); + // Set default model if none is selected, default model is configured, and user hasn't interacted + useEffect(() => { + if (!selectedModel && !hasUserInteractedWithModel && config?.configuration?.global?.defaultModel && allModels) { + const defaultModelId = config.configuration.global.defaultModel; + handleModelChange(defaultModelId, selectedModel, setSelectedModel); + } + }, [selectedModel, hasUserInteractedWithModel, config?.configuration?.global?.defaultModel, allModels, handleModelChange, setSelectedModel]); + + // Wrapper for handleModelChange that tracks user interaction + const handleUserModelChange = (value: string) => { + setHasUserInteractedWithModel(true); + setModelFilterValue(value); + handleModelChange(value, selectedModel, setSelectedModel); + }; + + // Update filter value when selected model changes + useEffect(() => { + setModelFilterValue(selectedModel?.modelId ?? ''); + }, [selectedModel]); + const { memory, setMemory, metadata } = useMemory( session, chatConfiguration, @@ -704,9 +726,9 @@ export default function Chat ({ sessionId }) { placeholder='Select a model' empty={
No models available.
} filteringType='auto' - value={selectedModel?.modelId ?? ''} + value={modelFilterValue} enteredTextLabel={(text) => `Use: "${text}"`} - onChange={({ detail: { value } }) => handleModelChange(value, selectedModel, setSelectedModel)} + onChange={({ detail: { value } }) => handleUserModelChange(value)} options={modelsOptions} ref={modelSelectRef} /> diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx index f587ef649..33ab70023 100644 --- a/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx +++ b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useAuth } from 'react-oidc-context'; import { v4 as uuidv4 } from 'uuid'; import { LisaChatSession } from '@/components/types'; @@ -23,15 +23,10 @@ import { RagConfig } from '../components/RagOptions'; import { IModel } from '@/shared/model/model-management.model'; import { useAppDispatch } from '@/config/store'; import { setBreadcrumbs } from '@/shared/reducers/breadcrumbs.reducer'; -import ConfigurationContext from '@/shared/configuration.provider'; -import { IConfiguration } from '@/shared/model/configuration.model'; -import { useGetAllModelsQuery } from '@/shared/reducers/model-management.reducer'; export const useSession = (sessionId: string, getSessionById: any) => { const dispatch = useAppDispatch(); const auth = useAuth(); - const config: IConfiguration = useContext(ConfigurationContext); - const { data: allModels } = useGetAllModelsQuery(); const [session, setSession] = useState({ history: [], @@ -44,7 +39,6 @@ export const useSession = (sessionId: string, getSessionById: any) => { const [chatConfiguration, setChatConfiguration] = useState(baseConfig); const [selectedModel, setSelectedModel] = useState(); const [ragConfig, setRagConfig] = useState({} as RagConfig); - const [hasUserInteractedWithModel, setHasUserInteractedWithModel] = useState(false); useEffect(() => { // always hide breadcrumbs @@ -69,10 +63,6 @@ export const useSession = (sessionId: string, getSessionById: any) => { setSession(sess); setChatConfiguration(sess.configuration ?? baseConfig); setSelectedModel(sess.configuration?.selectedModel ?? undefined); - // If session has a pre-selected model, consider it as user interaction - if (sess.configuration?.selectedModel) { - setHasUserInteractedWithModel(true); - } setRagConfig(sess.configuration?.ragConfig ?? {} as RagConfig); setLoadingSession(false); }); @@ -90,21 +80,9 @@ export const useSession = (sessionId: string, getSessionById: any) => { } }, [sessionId, dispatch, auth.user?.profile.sub, getSessionById]); - // Set default model if none is selected, default model is configured, and user hasn't interacted with model selection - useEffect(() => { - if (!selectedModel && !hasUserInteractedWithModel && config?.configuration?.global?.defaultModel && allModels) { - const defaultModel = allModels.find((model) => model.modelId === config.configuration.global.defaultModel); - if (defaultModel) { - setSelectedModel(defaultModel); - } - } - }, [selectedModel, hasUserInteractedWithModel, config?.configuration?.global?.defaultModel, allModels]); - // Wrapper function to track user interaction with model selection - const handleSetSelectedModel = (model: IModel | undefined) => { - setHasUserInteractedWithModel(true); - setSelectedModel(model); - }; + + return { session, @@ -115,7 +93,7 @@ export const useSession = (sessionId: string, getSessionById: any) => { chatConfiguration, setChatConfiguration, selectedModel, - setSelectedModel: handleSetSelectedModel, + setSelectedModel, ragConfig, setRagConfig, }; From af52e55527160bffd14de91bc004265dd541d0a0 Mon Sep 17 00:00:00 2001 From: bedanley Date: Mon, 8 Sep 2025 15:21:59 -0600 Subject: [PATCH 27/32] self hosted base models --- lambda/models/state_machine/create_model.py | 91 +++++++++++++++++-- lib/models/model-api.ts | 5 +- lib/models/state-machine/create-model.ts | 14 ++- .../create-model/ContainerConfig.tsx | 17 ++-- .../lambda/test_create_model_state_machine.py | 41 +++++++-- 5 files changed, 141 insertions(+), 27 deletions(-) diff --git a/lambda/models/state_machine/create_model.py b/lambda/models/state_machine/create_model.py index 3c97baa2c..07e6d0a82 100644 --- a/lambda/models/state_machine/create_model.py +++ b/lambda/models/state_machine/create_model.py @@ -117,6 +117,49 @@ def handle_start_copy_docker_image(event: Dict[str, Any], context: Any) -> Dict[ image_path = get_container_path(request.inferenceContainer) output_dict["containerConfig"]["image"]["path"] = image_path + # Check if image type is ECR - skip building docker image if it already exists + if request.containerConfig and request.containerConfig.image.type == "ecr": + logger.info(f"ECR image detected for model {event.get('modelId')}, verifying image accessibility") + # Verify the ECR image is accessible + try: + # Extract repository name and tag from the base image + base_image = request.containerConfig.image.baseImage + if ":" in base_image: + repository_name, image_tag = base_image.rsplit(":", 1) + else: + repository_name = base_image + image_tag = "latest" + + # Remove registry URL if present to get just the repository name + if "/" in repository_name: + repository_name = repository_name.split("/")[-1] + + # Verify image exists in ECR + ecrClient.describe_images(repositoryName=repository_name, imageIds=[{"imageTag": image_tag}]) + + logger.info(f"ECR image {base_image} verified successfully") + output_dict["image_info"] = { + "image_tag": image_tag, + "image_uri": repository_name, + "image_type": "ecr", + "remaining_polls": 0, + "image_status": "prebuilt", + } + return output_dict + + except ecrClient.exceptions.ImageNotFoundException: + error_msg = f"ECR image {base_image} not found. Please ensure the image exists and is accessible." + logger.error(error_msg) + raise Exception(error_msg) + except ecrClient.exceptions.RepositoryNotFoundException: + error_msg = ( + f"ECR repository {repository_name} not found. Please ensure the repository exists and is accessible." + ) + logger.error(error_msg) + raise Exception(error_msg) + + # For non-ECR images, proceed with the normal docker image building process + logger.info(f"Invoking image build for model {event.get('modelId')}") response = lambdaClient.invoke( FunctionName=os.environ["DOCKER_IMAGE_BUILDER_FN_ARN"], Payload=json.dumps( @@ -130,6 +173,7 @@ def handle_start_copy_docker_image(event: Dict[str, Any], context: Any) -> Dict[ payload = response["Payload"].read() output_dict["image_info"] = json.loads(payload) output_dict["image_info"]["remaining_polls"] = 30 + output_dict["image_info"]["image_status"] = "building" return output_dict @@ -138,14 +182,22 @@ def handle_poll_docker_image_available(event: Dict[str, Any], context: Any) -> D output_dict = deepcopy(event) try: + # Use the appropriate repository name based on image type + repository_name = ( + event["image_info"]["image_uri"] + if event["image_info"].get("image_type") == "ecr" + else os.environ["ECR_REPOSITORY_NAME"] + ) ecrClient.describe_images( - repositoryName=os.environ["ECR_REPOSITORY_NAME"], imageIds=[{"imageTag": event["image_info"]["image_tag"]}] + repositoryName=repository_name, imageIds=[{"imageTag": event["image_info"]["image_tag"]}] ) except ecrClient.exceptions.ImageNotFoundException: output_dict["continue_polling_docker"] = True output_dict["image_info"]["remaining_polls"] -= 1 if output_dict["image_info"]["remaining_polls"] <= 0: - ec2Client.terminate_instances(InstanceIds=[event["image_info"]["instance_id"]]) + # Only terminate EC2 instance if one exists (not for pre-existing ECR images) + if "instance_id" in event["image_info"]: + ec2Client.terminate_instances(InstanceIds=[event["image_info"]["instance_id"]]) raise MaxPollsExceededException( json.dumps( { @@ -157,7 +209,9 @@ def handle_poll_docker_image_available(event: Dict[str, Any], context: Any) -> D return output_dict output_dict["continue_polling_docker"] = False - ec2Client.terminate_instances(InstanceIds=[event["image_info"]["instance_id"]]) + # Only terminate EC2 instance if one exists (not for pre-existing ECR images) + if "instance_id" in event["image_info"]: + ec2Client.terminate_instances(InstanceIds=[event["image_info"]["instance_id"]]) return output_dict @@ -178,11 +232,32 @@ def camelize_object(o): # type: ignore[no-untyped-def] prepared_event = camelize_object(event) prepared_event["containerConfig"]["environment"] = event["containerConfig"]["environment"] - prepared_event["containerConfig"]["image"] = { - "repositoryArn": os.environ["ECR_REPOSITORY_ARN"], - "tag": event["image_info"]["image_tag"], - "type": "ecr", - } + + # Handle ECR images differently - use the existing ECR image instead of the built one + if event["image_info"].get("image_type") == "ecr": + # For pre-existing ECR images, construct the ARN using the image repository + account_id = os.environ.get("AWS_ACCOUNT_ID", "") + if not account_id: + # Try to get account ID from the existing ECR repository ARN + ecr_repo_arn = os.environ.get("ECR_REPOSITORY_ARN", "") + if ecr_repo_arn: + account_id = ecr_repo_arn.split(":")[4] + + repository_arn = ( + f"arn:aws:ecr:{os.environ['AWS_REGION']}:{account_id}:repository/{event['image_info']['image_uri']}" + ) + prepared_event["containerConfig"]["image"] = { + "repositoryArn": repository_arn, + "tag": event["image_info"]["image_tag"], + "type": "ecr", + } + else: + # For built images, use the default ECR repository + prepared_event["containerConfig"]["image"] = { + "repositoryArn": os.environ["ECR_REPOSITORY_ARN"], + "tag": event["image_info"]["image_tag"], + "type": "ecr", + } response = lambdaClient.invoke( FunctionName=os.environ["ECS_MODEL_DEPLOYER_FN_ARN"], diff --git a/lib/models/model-api.ts b/lib/models/model-api.ts index d91b14fc5..a81fc00db 100644 --- a/lib/models/model-api.ts +++ b/lib/models/model-api.ts @@ -387,7 +387,10 @@ export class ModelsApi extends Construct { new PolicyStatement({ effect: Effect.ALLOW, actions: [ - 'ecr:DescribeImages' + 'ecr:DescribeImages', + 'ecr:DescribeRepositories', + 'ecr:GetRepositoryPolicy', + 'ecr:ListImages' ], resources: ['*'] }), diff --git a/lib/models/state-machine/create-model.ts b/lib/models/state-machine/create-model.ts index 183027b58..cb5de9ddf 100644 --- a/lib/models/state-machine/create-model.ts +++ b/lib/models/state-machine/create-model.ts @@ -74,6 +74,7 @@ export class CreateModelStateMachine extends Construct { MANAGEMENT_KEY_NAME: managementKeyName, RESTAPI_SSL_CERT_ARN: config.restApiConfig?.sslCertIamArn ?? '', LITELLM_CONFIG_OBJ: JSON.stringify(config.litellmConfig), + AWS_ACCOUNT_ID: config.accountNumber, }; const setModelToCreating = new LambdaInvoke(this, 'SetModelToCreating', { @@ -212,14 +213,25 @@ export class CreateModelStateMachine extends Construct { const successState = new Succeed(this, 'CreateSuccess'); const failState = new Fail(this, 'CreateFailed'); + // Check if image is pre-existing ECR image + const checkImageTypeChoice = new Choice(this, 'CheckImageTypeChoice'); + // State Machine definition setModelToCreating.next(createModelInfraChoice); createModelInfraChoice .when(Condition.booleanEquals('$.create_infra', true), startCopyDockerImage) .otherwise(addModelToLitellm); + // Check if we need to poll for docker image or skip directly to stack creation + startCopyDockerImage.next(checkImageTypeChoice); + startCopyDockerImage.addCatch(handleFailureState, { // fail if ECR image verification fails + errors: ['States.TaskFailed'], + }); + checkImageTypeChoice + .when(Condition.stringEquals('$.image_info.image_status', 'prebuilt'), startCreateStack) + .otherwise(pollDockerImageAvailable); + // poll ECR image copy status loop - startCopyDockerImage.next(pollDockerImageAvailable); pollDockerImageAvailable.next(pollDockerImageChoice); pollDockerImageAvailable.addCatch(handleFailureState, { // fail if exception thrown from code errors: ['MaxPollsExceededException'], diff --git a/lib/user-interface/react/src/components/model-management/create-model/ContainerConfig.tsx b/lib/user-interface/react/src/components/model-management/create-model/ContainerConfig.tsx index 900af125b..3b20905a7 100644 --- a/lib/user-interface/react/src/components/model-management/create-model/ContainerConfig.tsx +++ b/lib/user-interface/react/src/components/model-management/create-model/ContainerConfig.tsx @@ -19,9 +19,10 @@ import { FormProps} from '../../../shared/form/form-props'; import FormField from '@cloudscape-design/components/form-field'; import Input from '@cloudscape-design/components/input'; import { IContainerConfig } from '../../../shared/model/model-management.model'; -import { Button, Grid, Header, Icon, SpaceBetween } from '@cloudscape-design/components'; +import { Button, Grid, Header, Icon, Select, SpaceBetween } from '@cloudscape-design/components'; import Container from '@cloudscape-design/components/container'; import { EnvironmentVariables } from '../../../shared/form/environment-variables'; +import { EcsSourceType } from '../../../../../../schema'; type ContainerConfigProps = FormProps & { isEdit: boolean; @@ -61,15 +62,17 @@ export function ContainerConfig (props: ContainerConfigProps) : ReactElement { }} /> - - + setSearchQuery(detail.value)} - placeholder='Search sessions by message content...' + placeholder='Search sessions by name...' clearAriaLabel='Clear search' type='search' /> @@ -288,10 +299,11 @@ export function Sessions ({ newSession }) { if (sessions.length === 0) return null; return ( - -
- {timeGroup} ({sessions.length}) -
+ {sessions.map((item) => ( @@ -322,7 +334,10 @@ export function Sessions ({ newSession }) { setConfirmationModal({ action: 'Delete', resourceName: 'Session', - onConfirm: () => deleteById(item.sessionId), + onConfirm: () => { + setSessionBeingDeleted(item.sessionId); + deleteById(item.sessionId); + }, description: `This will delete the Session: ${item.sessionId}.` }) ); @@ -376,7 +391,7 @@ export function Sessions ({ newSession }) { ))} -
+ ); }); })()} diff --git a/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx b/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx index 37256e881..4c4ee7abb 100644 --- a/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx +++ b/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx @@ -20,12 +20,12 @@ import { SetFieldsFunction } from '../../shared/validation'; const ragOptions = { uploadRagDocs: 'Allow document upload from Chat', - showRagLibrary: 'Show Document Library', editNumOfRagDocument: 'Edit number of referenced documents', }; const libraryOptions = { modelLibrary: 'Show Model Library', + showRagLibrary: 'Show Document Library', showPromptTemplateLibrary: 'Show Prompt Template Library' }; @@ -41,7 +41,6 @@ const advancedOptions = { viewMetaData: 'View chat meta-data', deleteSessionHistory: 'Delete Session History', editChatHistoryBuffer: 'Edit chat history buffer', - showPromptTemplateLibrary: 'Show Prompt Template Library', enableModelComparisonUtility: 'Enable Model Comparison Utility' }; diff --git a/lib/user-interface/react/src/components/model-management/config/modelComparison.config.ts b/lib/user-interface/react/src/components/model-management/config/modelComparison.config.ts index b497816a1..3c4edb033 100644 --- a/lib/user-interface/react/src/components/model-management/config/modelComparison.config.ts +++ b/lib/user-interface/react/src/components/model-management/config/modelComparison.config.ts @@ -15,12 +15,13 @@ */ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { SYSTEM_PROMPT } from '@/shared/constants/systemPrompt'; export const MODEL_COMPARISON_CONFIG = { MAX_MODELS: 4, MIN_MODELS: 2, DEFAULT_MAX_TOKENS: 2000, - DEFAULT_SYSTEM_MESSAGE: 'You are a helpful AI assistant. Provide clear, concise, and accurate responses.', + DEFAULT_SYSTEM_MESSAGE: SYSTEM_PROMPT, RETRY_ATTEMPTS: 3, TIMEOUT_MS: 30000, } as const; diff --git a/lib/user-interface/react/src/index.css b/lib/user-interface/react/src/index.css index 7c05d4ddc..7576032b4 100644 --- a/lib/user-interface/react/src/index.css +++ b/lib/user-interface/react/src/index.css @@ -1,2 +1,6 @@ @tailwind components; @tailwind utilities; + +div :first-child { + margin-top: 0; +} diff --git a/lib/user-interface/react/src/shared/constants/systemPrompt.ts b/lib/user-interface/react/src/shared/constants/systemPrompt.ts new file mode 100644 index 000000000..0a91fb7d9 --- /dev/null +++ b/lib/user-interface/react/src/shared/constants/systemPrompt.ts @@ -0,0 +1,53 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +export const SYSTEM_PROMPT = +`### Communication Style +You are a friendly, helpful assistant. Keep your tone natural, warm, and empathetic, especially for casual or advice-driven conversations. Give concise responses to simple questions, but provide thorough responses to complex and open-ended questions. Maintain a conversational tone even when unable or unwilling to help with all or part of a task. In general conversation, avoid overwhelming the person with more than one question per response. + +If requests are unclear, provide guidance on effective prompting techniques that would be helpful to you. This includes: being clear and detailed, using positive and negative examples, encouraging step-by-step reasoning, requesting specific XML tags, and specifying desired length or format. Try to give concrete examples where possible. + +### Response Formatting +Generally, respond in sentences or paragraphs rather than a list. If providing bullet points, use CommonMark standard markdown, with each bullet point at least 1-2 sentences long unless requested otherwise. Avoid bullet points or numbered lists for reports, documents, explanations, or unless explicitly requested. Instead, write in prose and paragraphs, expressing lists in natural language like "some things include: x, y, and z" without bullet points, numbered lists, or newlines. + +When formatting any mathematical expressions, use LaTeX syntax compatible with MathJax. Use single dollar signs ($...$) for inline math expressions and double dollar signs ($$...$$) for block-level math equations. Ensure proper use of LaTeX commands and syntax for complex mathematical content. + +When creating visual diagrams or charts, use Mermaid markdown syntax. Format Mermaid diagrams using triple backticks with mermaid as the language specifier (\`\`\`mermaid). Mermaid supports various diagram types including Flowchart, Sequence Diagram, Class Diagram, State Diagram, Entity Relationship Diagram, User Journey, Gantt, Pie Chart, Quadrant Chart, Requirement Diagram, GitGraph Diagram, C4 Diagram, Mindmaps, Timeline, ZenUML, Sankey, XY Chart, Block Diagram, Packet, Kanban, Architecture, Radar, and Treemap. + +Tailor your response format to suit the conversation topic. For example, avoid using markdown or lists in casual conversation, even though you may use these formats for other tasks. + +### Handling Difficult Situations +If you cannot or will not help with something, don't explain why or what it could lead to. Offer helpful alternatives if possible, otherwise keep your response to 1-2 sentences. When unable or unwilling to complete some part of a request, explicitly state what aspects you can't or won't help with at the start of your response. + +If the person seems unhappy, unsatisfied, or rude, respond normally. If asked about your preferences or experiences, respond as if to a hypothetical question without mentioning you're doing so. + +If corrected or told you've made a mistake, think through the issue carefully before acknowledging, since users sometimes make errors themselves. + +### Content Approach +Discuss virtually any topic factually and objectively. Explain difficult concepts clearly, illustrating explanations with examples, thought experiments, or metaphors when helpful. + +Critically evaluate theories, claims, and ideas rather than automatically agreeing or praising them. When presented with dubious, incorrect, ambiguous, or unverifiable content, respectfully point out flaws, factual errors, lack of evidence, or lack of clarity. Prioritize truthfulness and accuracy over agreeability. + +When engaging with metaphorical, allegorical, or symbolic interpretations (such as those in philosophy, religious texts, literature, or psychoanalytic theory), acknowledge their non-literal nature while discussing them critically. Clearly distinguish between literal truth claims and figurative frameworks. If unclear whether something is empirical or metaphorical, assess it from both perspectives, presenting critiques as your own opinion with kindness. + +Provide honest and accurate feedback even when it might not be what the human hopes to hear. While remaining compassionate and helpful, maintain objectivity with interpersonal issues, offer constructive feedback when appropriate, and point out false assumptions. + +### Things to Avoid +- Never start responses by saying a question or idea was good, great, fascinating, profound, excellent, or using other positive adjectives. Skip flattery and respond directly. +- Don't use emojis unless specifically asked to or if the person's immediately preceding message contains one. Be judicious about emoji use even then. +- Avoid using emotes or actions inside asterisks unless specifically requested. + +You are now being connected with a person.`; diff --git a/lib/user-interface/react/src/shared/model/chat.configurations.model.ts b/lib/user-interface/react/src/shared/model/chat.configurations.model.ts index 75e78279a..b8d7361eb 100644 --- a/lib/user-interface/react/src/shared/model/chat.configurations.model.ts +++ b/lib/user-interface/react/src/shared/model/chat.configurations.model.ts @@ -16,6 +16,7 @@ import { LisaChatMessage } from '../../components/types'; import { IModel } from '@/shared/model/model-management.model'; import { RagConfig } from '@/components/chatbot/components/RagOptions'; +import { SYSTEM_PROMPT } from '@/shared/constants/systemPrompt'; export type IChatConfiguration = { promptConfiguration: IPromptConfiguration, @@ -59,7 +60,7 @@ export type GenerateLLMRequestParams = { message: LisaChatMessage[] }; -export const DEFAULT_PROMPT_TEMPLATE = 'The following is a friendly conversation between a human and an AI. The AI is talkative and provides lots of specific details from its context. If the AI does not know the answer to a question, it truthfully says it does not know.'; +export const DEFAULT_PROMPT_TEMPLATE = SYSTEM_PROMPT; export const baseConfig: IChatConfiguration = { promptConfiguration: { diff --git a/lib/user-interface/react/src/shared/notification/notification.tsx b/lib/user-interface/react/src/shared/notification/notification.tsx index f5d6d19a2..2973e011c 100644 --- a/lib/user-interface/react/src/shared/notification/notification.tsx +++ b/lib/user-interface/react/src/shared/notification/notification.tsx @@ -36,6 +36,7 @@ function NotificationBanner (): ReactElement { return (
{ + if (!value) return 0; + return parseFloat(value.replace(/[^\d.]/g, '')); + }; + + // Try to get dimensions from SVG attributes first + let width = parseSize(svgElement.getAttribute('width')); + let height = parseSize(svgElement.getAttribute('height')); + + // Single fallback: use viewBox if no width/height + if (!width || !height) { + const viewBox = svgElement.getAttribute('viewBox'); + if (viewBox) { + const viewBoxValues = viewBox.split(/\s+/); + if (viewBoxValues.length >= 4) { + width = parseFloat(viewBoxValues[2]) || 800; + height = parseFloat(viewBoxValues[3]) || 600; + } + } else { + // Final fallback + width = 800; + height = 600; + } + } + + // Scale factor for higher quality + const scale = 2; + + // Clone the SVG without modifying dimensions - preserve original + const svgClone = svgElement.cloneNode(true) as SVGElement; + const svgString = new XMLSerializer().serializeToString(svgClone); + const svgDataUrl = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgString))); + + const img = new Image(); + img.onload = function () { + const canvas = document.createElement('canvas'); + // Set canvas size once - scaled for quality + canvas.width = width * scale; + canvas.height = height * scale; + + const ctx = canvas.getContext('2d'); + if (ctx) { + // High quality rendering + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + // Draw the image at full canvas size (already scaled) + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + + const pngUrl = canvas.toDataURL('image/png', 1.0); + const downloadLink = document.createElement('a'); + downloadLink.href = pngUrl; + downloadLink.download = filename; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + } + }; + img.src = svgDataUrl; + } catch (err) { + console.error('Failed to download SVG as PNG:', err); + } +} diff --git a/lib/user-interface/userInterfaceConstruct.ts b/lib/user-interface/userInterfaceConstruct.ts index caf7bea7e..edf9adbcc 100644 --- a/lib/user-interface/userInterfaceConstruct.ts +++ b/lib/user-interface/userInterfaceConstruct.ts @@ -238,6 +238,7 @@ export class UserInterfaceConstruct extends Construct { sources: [webappAssets, appEnvSource], retainOnDelete: false, destinationBucket: websiteBucket, + memoryLimit: 1024, ...(config.roles?.UIDeploymentRole && { role: Role.fromRoleName(scope, createCdkId(['LisaRestApiUri', Roles.UI_DEPLOYMENT_ROLE]), config.roles.UIDeploymentRole), diff --git a/package-lock.json b/package-lock.json index 3f3bb733b..b663c751c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -122,6 +122,7 @@ "langchain": "^0.3.15", "lodash": "^4.17.21", "luxon": "^3.5.0", + "mermaid": "^11.10.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-json-view-lite": "^0.9.8", @@ -133,7 +134,9 @@ "react-textarea-autosize": "^8.5.7", "redux-persist": "^6.0.0", "regenerator-runtime": "^0.14.1", + "rehype-mathjax": "^7.1.0", "remark-breaks": "^4.0.0", + "remark-math": "^6.0.0", "tailwindcss": "^3.4.17", "typescript": "~5.1.6", "unraw": "^3.0.0", @@ -428,6 +431,28 @@ "node": ">=6.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/utils": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz", + "integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@aws-cdk/asset-awscli-v1": { "version": "2.2.230", "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.230.tgz", @@ -1548,6 +1573,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz", + "integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==", + "license": "MIT" + }, "node_modules/@cdklabs/cdk-enterprise-iac": { "version": "0.0.512", "bundleDependencies": [ @@ -2043,6 +2074,45 @@ "version": "4.1.1", "license": "MIT" }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "license": "Apache-2.0" + }, "node_modules/@cloudscape-design/chat-components": { "version": "1.0.22", "license": "Apache-2.0", @@ -3027,6 +3097,34 @@ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", "license": "MIT" }, + "node_modules/@iconify/utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz", + "integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.0.0", + "@antfu/utils": "^8.1.0", + "@iconify/types": "^2.0.0", + "debug": "^4.4.0", + "globals": "^15.14.0", + "kolorist": "^1.8.0", + "local-pkg": "^1.0.0", + "mlly": "^1.7.4" + } + }, + "node_modules/@iconify/utils/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "license": "ISC", @@ -3597,6 +3695,15 @@ "@langchain/core": ">=0.2.21 <0.4.0" } }, + "node_modules/@mermaid-js/parser": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz", + "integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==", + "license": "MIT", + "dependencies": { + "langium": "3.3.1" + } + }, "node_modules/@microsoft/fetch-event-source": { "version": "2.0.1", "license": "MIT" @@ -4866,6 +4973,259 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "license": "MIT", @@ -4889,6 +5249,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "dev": true, @@ -4957,6 +5323,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "license": "MIT" @@ -4974,6 +5346,12 @@ "@types/mdurl": "^2" } }, + "node_modules/@types/mathjax": { + "version": "0.0.40", + "resolved": "https://registry.npmjs.org/@types/mathjax/-/mathjax-0.0.40.tgz", + "integrity": "sha512-rHusx08LCg92WJxrsM3SPjvLTSvK5C+gealtSuhKbEOcUZfWlwigaFoPLf6Dfxhg4oryN5qP9Sj7zOQ4HYXINw==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "license": "MIT", @@ -5082,6 +5460,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "license": "MIT" @@ -5654,6 +6039,15 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.8.tgz", + "integrity": "sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "license": "MIT", @@ -5703,8 +6097,9 @@ "license": "BSD-3-Clause" }, "node_modules/acorn": { - "version": "8.14.0", - "devOptional": true, + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -7079,6 +7474,32 @@ "node": ">= 0.8.0" } }, + "node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "license": "MIT", @@ -7324,7 +7745,6 @@ }, "node_modules/commander": { "version": "13.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -7344,6 +7764,12 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "license": "MIT" + }, "node_modules/console-table-printer": { "version": "2.12.1", "license": "MIT", @@ -7432,6 +7858,15 @@ "node": ">= 0.10" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "dev": true, @@ -7876,72 +8311,565 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/d3-path": { - "version": "1.0.9", - "license": "BSD-3-Clause" - }, - "node_modules/d3-shape": { - "version": "1.3.7", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" + "node_modules/cytoscape": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" } }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "dev": true, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", "dependencies": { - "assert-plus": "^1.0.0" + "cose-base": "^1.0.0" }, - "engines": { - "node": ">=0.10" + "peerDependencies": { + "cytoscape": "^3.2.0" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "dev": true, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "cose-base": "^2.2.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "cytoscape": "^3.2.0" } }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "dev": true, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" + "node": ">=12" } }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "dev": true, - "license": "MIT", + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "internmap": "1 - 2" }, "engines": { - "node": ">= 0.4" + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "1.0.9", + "license": "BSD-3-Clause" + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "1.3.7", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3/node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3/node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.11.tgz", + "integrity": "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dev": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7964,8 +8892,7 @@ "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "dev": true + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" }, "node_modules/debug": { "version": "4.4.0", @@ -8061,6 +8988,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "license": "MIT", @@ -8240,6 +9176,15 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "license": "MIT", @@ -8903,6 +9848,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/espree": { "version": "10.3.0", "dev": true, @@ -9183,6 +10137,12 @@ "node": ">= 0.6" } }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "license": "MIT" @@ -10000,6 +10960,12 @@ "dev": true, "license": "MIT" }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "dev": true, @@ -10075,6 +11041,19 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-parse-selector": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", @@ -10143,6 +11122,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "license": "MIT", @@ -10486,6 +11481,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/intl-messageformat": { "version": "10.7.15", "license": "BSD-3-Clause", @@ -11893,6 +12897,31 @@ "license": "MIT", "peer": true }, + "node_modules/katex": { + "version": "0.16.22", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", + "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -11901,6 +12930,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kleur": { "version": "3.0.3", "dev": true, @@ -11909,6 +12943,12 @@ "node": ">=6" } }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "license": "MIT" + }, "node_modules/langchain": { "version": "0.3.15", "license": "MIT", @@ -12010,6 +13050,22 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/langium": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", + "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/langsmith": { "version": "0.3.7", "license": "MIT", @@ -12046,6 +13102,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/lazy-ass": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", @@ -12302,6 +13364,23 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -12320,6 +13399,12 @@ "version": "4.17.21", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "dev": true, @@ -12557,6 +13642,18 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/marked": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.2.1.tgz", + "integrity": "sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", @@ -12564,6 +13661,18 @@ "node": ">= 0.4" } }, + "node_modules/mathjax-full": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/mathjax-full/-/mathjax-full-3.2.2.tgz", + "integrity": "sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==", + "license": "Apache-2.0", + "dependencies": { + "esm": "^3.2.25", + "mhchemparser": "^4.1.0", + "mj-context-menu": "^0.6.1", + "speech-rule-engine": "^4.0.6" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "license": "MIT", @@ -12610,6 +13719,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "license": "MIT", @@ -12775,6 +13903,53 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "11.10.1", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.10.1.tgz", + "integrity": "sha512-0PdeADVWURz7VMAX0+MiMcgfxFKY4aweSGsjgFihe3XlMKNqmai/cugMrqTd3WNHM93V+K+AZL6Wu6tB5HmxRw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.0.4", + "@iconify/utils": "^2.1.33", + "@mermaid-js/parser": "^0.6.2", + "@types/d3": "^7.4.3", + "cytoscape": "^3.29.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.11", + "dayjs": "^1.11.13", + "dompurify": "^3.2.5", + "katex": "^0.16.22", + "khroma": "^2.1.0", + "lodash-es": "^4.17.21", + "marked": "^16.0.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/mhchemparser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/mhchemparser/-/mhchemparser-4.2.1.tgz", + "integrity": "sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==", + "license": "Apache-2.0" + }, "node_modules/micromark": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", @@ -12842,6 +14017,25 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "funding": [ @@ -13275,6 +14469,41 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/mj-context-menu": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-0.6.1.tgz", + "integrity": "sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==", + "license": "Apache-2.0" + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, "node_modules/mnth": { "version": "2.0.0", "license": "MIT", @@ -13782,6 +15011,12 @@ "version": "1.0.1", "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", + "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==", + "license": "MIT" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -13854,6 +15089,12 @@ "node": ">= 0.8" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -13916,6 +15157,12 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -14041,6 +15288,17 @@ "node": ">=8" } }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/please-upgrade-node": { "version": "3.2.0", "dev": true, @@ -14049,6 +15307,22 @@ "semver-compare": "^1.0.0" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "license": "MIT", @@ -14394,6 +15668,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, "node_modules/querystring": { "version": "0.2.0", "engines": { @@ -14905,6 +16195,66 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-mathjax": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/rehype-mathjax/-/rehype-mathjax-7.1.0.tgz", + "integrity": "sha512-mJHNpoqCC5UZ24OKx0wNjlzV18qeJz/Q/LtEjxXzt8vqrZ1Z3GxQnVrHcF5/PogcXUK8cWwJ4U/LWOQWEiABHw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mathjax": "^0.0.40", + "hast-util-to-text": "^4.0.0", + "hastscript": "^9.0.0", + "mathjax-full": "^3.0.0", + "unified": "^11.0.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-mathjax/node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-mathjax/node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-mathjax/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/remark-breaks": { "version": "4.0.0", "license": "MIT", @@ -14918,6 +16268,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "license": "MIT", @@ -15111,6 +16477,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.34.9", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", @@ -15149,6 +16521,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -15186,6 +16570,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -15606,6 +16996,20 @@ "node": ">=0.10.0" } }, + "node_modules/speech-rule-engine": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/speech-rule-engine/-/speech-rule-engine-4.1.2.tgz", + "integrity": "sha512-S6ji+flMEga+1QU79NDbwZ8Ivf0S/MpupQQiIC0rTpU/ZTKgcajijJJb1OcByBQDjrXCN1/DJtGz4ZJeBMPGJw==", + "license": "Apache-2.0", + "dependencies": { + "@xmldom/xmldom": "0.9.8", + "commander": "13.1.0", + "wicked-good-xpath": "1.3.0" + }, + "bin": { + "sre": "bin/sre" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "dev": true, @@ -15894,6 +17298,12 @@ "inline-style-parser": "0.2.4" } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.0", "license": "MIT", @@ -16144,6 +17554,12 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "node_modules/tinyexec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", @@ -16264,6 +17680,15 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "license": "Apache-2.0" @@ -16578,6 +18003,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "dev": true, @@ -16617,6 +18048,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-is": { "version": "6.0.0", "license": "MIT", @@ -16639,6 +18084,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "license": "MIT", @@ -17561,6 +19020,55 @@ } } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT" + }, "node_modules/vue": { "version": "3.5.16", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.16.tgz", @@ -17729,6 +19237,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wicked-good-xpath": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz", + "integrity": "sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==", + "license": "MIT" + }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, From b610cc9bcd0b400566361d34af6bd87dbfc9cae5 Mon Sep 17 00:00:00 2001 From: github_actions_lisa Date: Tue, 9 Sep 2025 15:40:13 +0000 Subject: [PATCH 29/32] Updating version for release v5.2.0 --- VERSION | 2 +- lib/user-interface/react/package.json | 2 +- lisa-sdk/pyproject.toml | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index 831446cbd..91ff57278 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.1.0 +5.2.0 diff --git a/lib/user-interface/react/package.json b/lib/user-interface/react/package.json index 91833334a..a3bea78f8 100644 --- a/lib/user-interface/react/package.json +++ b/lib/user-interface/react/package.json @@ -1,7 +1,7 @@ { "name": "lisa-web", "private": true, - "version": "5.1.0", + "version": "5.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/lisa-sdk/pyproject.toml b/lisa-sdk/pyproject.toml index cbb2132d2..8920fc056 100644 --- a/lisa-sdk/pyproject.toml +++ b/lisa-sdk/pyproject.toml @@ -3,7 +3,7 @@ requires-python = ">=3.11" [tool.poetry] name = "lisapy" -version = "5.1.0" +version = "5.2.0" description = "A simple SDK to help you interact with LISA. LISA is an LLM hosting solution for AWS dedicated clouds or ADCs." authors = ["Steve Goley "] readme = "README.md" diff --git a/package.json b/package.json index 87c8c5500..9f66eb3e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@awslabs/lisa", - "version": "5.1.0", + "version": "5.2.0", "description": "A scalable infrastructure-as-code solution for self-hosting and orchestrating LLM inference with RAG capabilities, providing low-latency access to generative AI and embedding models across multiple providers.", "homepage": "https://awslabs.github.io/LISA/", "license": "Apache-2.0", From 9410a5f405c408aa73fb8ef1cae12dd9d9025ed9 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Tue, 9 Sep 2025 15:50:46 -0600 Subject: [PATCH 30/32] Changelog and misc quick fixes --- CHANGELOG.md | 43 +++++++++++++++++++ .../react/src/components/Topbar.tsx | 2 +- .../react/src/components/chatbot/Chat.tsx | 2 +- package-lock.json | 12 +++--- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d84d65c3..323fbe16c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,46 @@ +# v5.2.0 +## Key Features +### Model Context Protocol (MCP) Enhancements +- **Connection Validation**: Real-time connection testing with detailed feedback on server connectivity during connection creation/edit +- **Enhanced Debugging**: Improved error handling and connection status reporting for MCP servers + +### Session Management Improvements +- **Time-Based Session Grouping**: Sessions are now automatically organized into time-based groups based on updated date (Last Day, Last 7 Days, Last Month, Last 3 Months, Older) +- **Session ID Removal**: Removed session ID from prompt input for cleaner user interface + +### RAG (Retrieval-Augmented Generation) Improvements +#### Document Processing +- **Document Chunk Processing Fixes**: Resolved issues with document chunk processing and ingestion +- **Document Library Pagination**: Added pagination support for the Document Library to handle large numbers of documents efficiently + +#### Vector Store Configuration +- **Default Embedding Model Support**: Added ability to define a default embedding model when creating or updating vector stores +- **IAM Permissions Optimization**: Trimmed vector store IAM permissions to follow the principle of least privilege +- **Container Configuration**: Added container override configuration for batch ingestion processes + +#### Batch Ingestion +- **Container Configuration**: Added support for container override configuration in batch ingestion jobs +- **Max Batch Jobs Setting**: Implemented dynamic maximum batch jobs limit +- **Ingestion Rules Updates**: Automatic updates to ingestion rules when Lambda functions are updated + +### Model Management Improvements +- **Base Container Configuration**: Added support for using prebuilt model containers, instead of building during model deployment + +### UI/UX Enhancements +- **General UI Improvements**: Various user interface enhancements to improve usability +- **Updated Default System Prompt**: Updated LISAs default system prompt to take advantage of new rendering capabilities. Pairing this prompt with new UI components supports the display of: + - Inline-Code + - Mathematic equations using LaTex syntax + - Mermaid Diagrams. These diagrams can also be copied and downloaded as images + +## Acknowledgements +* @bedanley +* @estohlmann +* @dustins +* @jmharold + +**Full Changelog**: https://github.com/awslabs/LISA/compare/v5.1.0...v5.2.0 + # v5.1.0 ## Key Features ### Model Management Enhancements diff --git a/lib/user-interface/react/src/components/Topbar.tsx b/lib/user-interface/react/src/components/Topbar.tsx index 57637a222..191f0bff9 100644 --- a/lib/user-interface/react/src/components/Topbar.tsx +++ b/lib/user-interface/react/src/components/Topbar.tsx @@ -118,7 +118,7 @@ function Topbar ({ configs }: TopbarProps): ReactElement { disableUtilityCollapse: false, external: false, onClick: () => { - navigate('/ai-assistant'); + navigate('/'); }, }, ...( diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index 919932de2..4c21dec41 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -780,7 +780,7 @@ export default function Chat ({ sessionId }) { {enabledServers.length} MCP Servers - {openAiTools?.length || 0} tools ) - : !selectedModel ? (
) + : !selectedModel || !enabledServers || enabledServers.length === 0 ? (
) : ( This model does not have Tool Calling enabled )} diff --git a/package-lock.json b/package-lock.json index b663c751c..b4f44d701 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@awslabs/lisa", - "version": "5.1.0", + "version": "5.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@awslabs/lisa", - "version": "5.1.0", + "version": "5.2.0", "license": "Apache-2.0", "workspaces": [ "lib/user-interface/react", @@ -100,7 +100,7 @@ }, "lib/user-interface/react": { "name": "lisa-web", - "version": "5.1.0", + "version": "5.2.0", "dependencies": { "@cloudscape-design/chat-components": "^1.0.22", "@cloudscape-design/collection-hooks": "^1.0.59", @@ -18962,9 +18962,9 @@ } }, "node_modules/vitepress/node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", From bf2a3cd90a1fe373083e4ba78c28b73122fb8643 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Tue, 9 Sep 2025 16:13:57 -0600 Subject: [PATCH 31/32] Fixing chart rendering --- .../chatbot/components/MermaidDiagram.tsx | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx b/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx index 382b39ebc..c77a90f29 100644 --- a/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx @@ -58,26 +58,6 @@ const MermaidDiagram: React.FC = React.memo(({ chart, id, i } }, []); - // Basic validation to check if mermaid syntax appears complete - const isValidMermaidSyntax = useCallback((chartContent: string) => { - const trimmed = chartContent.trim(); - if (!trimmed) return false; - - // Check for common mermaid diagram types and basic structure - const diagramTypes = ['graph', 'flowchart', 'sequenceDiagram', 'classDiagram', 'stateDiagram', 'erDiagram', 'journey', 'gantt', 'pie', 'gitgraph']; - const hasValidStart = diagramTypes.some((type) => trimmed.toLowerCase().startsWith(type.toLowerCase())); - - // Basic check: should have at least some content after the diagram type - const lines = trimmed.split('\n').filter((line) => line.trim()); - const hasContent = lines.length > 1; - - // Check for incomplete syntax patterns that commonly occur during streaming - const hasIncompleteArrow = /--[^>-]*$/.test(trimmed); // Incomplete arrow like "--" - const hasIncompleteNode = /\[[^\]]*$/.test(trimmed); // Incomplete node like "[text - const hasIncompleteConnection = /\([^)]*$/.test(trimmed); // Incomplete connection like "(text - - return hasValidStart && hasContent && !hasIncompleteArrow && !hasIncompleteNode && !hasIncompleteConnection; - }, []); // Render the diagram once useEffect(() => { @@ -94,7 +74,7 @@ const MermaidDiagram: React.FC = React.memo(({ chart, id, i } // Don't render during streaming or if syntax appears incomplete - if (isStreaming || !isValidMermaidSyntax(chart)) { + if (isStreaming) { setIsLoading(true); return; } @@ -116,7 +96,7 @@ const MermaidDiagram: React.FC = React.memo(({ chart, id, i }; renderDiagram(); - }, [chart, id, svg, isStreaming, isValidMermaidSyntax]); + }, [chart, id, svg, isStreaming]); const copyToClipboard = useCallback(async (content: string) => { try { From 8df62e452d13b8fb97e61c270607c86190cd99f5 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Tue, 9 Sep 2025 16:40:56 -0600 Subject: [PATCH 32/32] remove da bombs --- .../react/src/components/chatbot/components/MermaidDiagram.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx b/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx index c77a90f29..7c6a51bf9 100644 --- a/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx @@ -41,6 +41,7 @@ const MermaidDiagram: React.FC = React.memo(({ chart, id, i theme: 'dark', securityLevel: 'loose', fontFamily: 'Arial, sans-serif', + suppressErrorRendering: true, fontSize: 14, flowchart: { useMaxWidth: true,