├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── ui-check.yml │ └── yarn-audit.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── THIRD-PARTY-LICENSES ├── amplify.yml ├── amplify ├── .config │ └── project-config.json ├── backend │ ├── auth │ │ └── healthScribeDemoAuth │ │ │ ├── cli-inputs.json │ │ │ └── override.ts │ ├── awscloudformation │ │ └── override.ts │ ├── backend-config.json │ ├── custom │ │ └── addBucketLogging │ │ │ ├── .npmrc │ │ │ ├── cdk-stack.ts │ │ │ ├── package.json │ │ │ ├── tsconfig.json │ │ │ └── yarn.lock │ ├── function │ │ └── addBucketLogging │ │ │ ├── addBucketLogging-cloudformation-template.json │ │ │ ├── amplify.state │ │ │ ├── custom-policies.json │ │ │ ├── function-parameters.json │ │ │ └── src │ │ │ ├── .eslintrc.js │ │ │ ├── cfn-reply.js │ │ │ ├── index.js │ │ │ ├── package.json │ │ │ └── yarn.lock │ ├── package.json │ ├── storage │ │ └── healthScribeDemoStorage │ │ │ ├── cli-inputs.json │ │ │ └── override.ts │ ├── tags.json │ ├── tsconfig.json │ ├── types │ │ └── amplify-dependent-resources-ref.d.ts │ └── yarn.lock ├── cli.json └── hooks │ └── post-push.js ├── docs ├── amplify.md └── deploy.md ├── images ├── AWS-HealthScribe-Demo-Architecture.png └── UI-Sample.gif ├── index.html ├── package.json ├── public ├── favicon.svg └── record.png ├── src ├── Globals.d.ts ├── components │ ├── App │ │ ├── App.tsx │ │ └── index.ts │ ├── Auth │ │ ├── Auth.tsx │ │ └── index.ts │ ├── Breadcrumbs │ │ ├── Breadcrumbs.tsx │ │ └── index.ts │ ├── Common │ │ ├── AudioControls.module.css │ │ ├── AudioControls.tsx │ │ └── ValueWithLabel.tsx │ ├── Conversation │ │ ├── Common │ │ │ ├── ComprehendMedical.tsx │ │ │ ├── LoadingContainer.tsx │ │ │ ├── OntologyLinking.tsx │ │ │ ├── OntologyLinkingData.tsx │ │ │ ├── ScrollingContainer.module.css │ │ │ └── ScrollingContainer.tsx │ │ ├── Conversation.tsx │ │ ├── ConversationHeader.tsx │ │ ├── LeftPanel │ │ │ ├── ClinicalInsight.tsx │ │ │ ├── ClinicalInsightAttributesTable.tsx │ │ │ ├── LeftPanel.module.css │ │ │ ├── LeftPanel.tsx │ │ │ ├── TranscriptSegment.tsx │ │ │ ├── WordPopover.tsx │ │ │ └── index.ts │ │ ├── RightPanel │ │ │ ├── RightPanel.tsx │ │ │ ├── RightPanelComponents.tsx │ │ │ ├── SummarizedConcepts.module.css │ │ │ ├── SummarizedConcepts.tsx │ │ │ ├── SummaryList.tsx │ │ │ ├── SummaryListComponents.tsx │ │ │ ├── index.ts │ │ │ ├── rightPanelUtils.ts │ │ │ ├── sectionOrder.ts │ │ │ └── summarizedConceptsUtils.ts │ │ ├── TopPanel │ │ │ ├── TopPanel.module.css │ │ │ ├── TopPanel.tsx │ │ │ ├── extractRegions.ts │ │ │ └── index.ts │ │ ├── ViewOutput │ │ │ ├── ViewOutput.tsx │ │ │ └── index.ts │ │ ├── index.ts │ │ └── types.ts │ ├── Conversations │ │ ├── Conversations.tsx │ │ ├── ConversationsFilter.tsx │ │ ├── ConversationsHeaderActions.tsx │ │ ├── DeleteConversation.tsx │ │ ├── TableEmptyState.tsx │ │ ├── TablePreferences.tsx │ │ ├── conversationsColumnDefs.tsx │ │ ├── conversationsPrefs.tsx │ │ └── index.ts │ ├── Debug │ │ ├── Debug.tsx │ │ └── index.ts │ ├── GenerateAudio │ │ ├── AudioLineBox.tsx │ │ ├── GenerateAudio.module.css │ │ ├── GenerateAudio.tsx │ │ ├── index.ts │ │ └── templates │ │ │ ├── knee.ts │ │ │ └── sleep.ts │ ├── NewConversation │ │ ├── AudioRecorder.module.css │ │ ├── AudioRecorder.tsx │ │ ├── Dropzone.module.css │ │ ├── Dropzone.tsx │ │ ├── FormComponents.tsx │ │ ├── NewConversation.module.css │ │ ├── NewConversation.tsx │ │ ├── formUtils.ts │ │ ├── index.ts │ │ └── types.ts │ ├── Settings │ │ ├── Common.tsx │ │ ├── Settings.tsx │ │ └── index.ts │ ├── SideNav │ │ ├── SideNav.tsx │ │ └── index.ts │ ├── SuspenseLoader │ │ ├── ModalLoader.tsx │ │ ├── SuspenseLoader.tsx │ │ └── index.ts │ ├── TopNav │ │ ├── TopNav.css │ │ ├── TopNav.tsx │ │ └── index.ts │ ├── Welcome │ │ ├── Welcome.module.css │ │ ├── Welcome.tsx │ │ ├── WelcomeHeader.tsx │ │ ├── WelcomeSections.tsx │ │ └── index.ts │ └── index.ts ├── hooks │ ├── useAudio.ts │ ├── useLocalStorage.ts │ ├── useNotification.tsx │ ├── useS3.ts │ └── useScroll.ts ├── main.tsx ├── store │ ├── appSettings │ │ ├── appSettings.type.ts │ │ ├── defaultSettings.ts │ │ ├── index.tsx │ │ └── settingOptions.ts │ ├── appTheme │ │ └── index.tsx │ ├── auth │ │ └── index.tsx │ └── notifications │ │ └── index.tsx ├── types │ ├── ComprehendMedical.ts │ ├── HealthScribeSummary.ts │ ├── HealthScribeTranscript.ts │ └── _HealthScribe.ts └── utils │ ├── ComprehendMedicalApi │ └── index.ts │ ├── HealthScribeApi │ └── index.ts │ ├── PollyApi │ └── index.ts │ ├── S3Api │ └── index.ts │ ├── Sdk │ └── index.ts │ ├── array.ts │ ├── getPercentageFromDecimal.ts │ ├── sleep.ts │ └── toTitleCase.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.mts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | node_modules/* -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | ecmaVersion: 2020, 6 | sourceType: 'module', 7 | ecmaFeatures: { 8 | jsx: true, 9 | }, 10 | }, 11 | settings: { 12 | react: { 13 | version: 'detect', 14 | }, 15 | 'import/resolver': { 16 | node: { 17 | paths: ['src'], 18 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 19 | }, 20 | }, 21 | }, 22 | env: { 23 | browser: true, 24 | amd: true, 25 | node: true, 26 | }, 27 | extends: [ 28 | 'eslint:recommended', 29 | 'plugin:@typescript-eslint/recommended', 30 | 'plugin:react/recommended', 31 | 'plugin:prettier/recommended', // Make sure this is always the last element in the array. 32 | ], 33 | plugins: ['prettier'], 34 | rules: { 35 | 'prettier/prettier': ['warn', {}, { usePrettierrc: true }], 36 | 'react/react-in-jsx-scope': 'warn', 37 | 'react/prop-types': 'off', 38 | '@typescript-eslint/explicit-function-return-type': 'off', 39 | 'no-unused-vars': 'off', // use @typescript-eslint/no-unused-vars, not base rule 40 | '@typescript-eslint/no-unused-vars': 'warn', 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /.github/workflows/ui-check.yml: -------------------------------------------------------------------------------- 1 | name: UI Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | type-check: 16 | name: UI TypeScript Check 17 | runs-on: ubuntu-latest 18 | 19 | defaults: 20 | run: 21 | working-directory: ./src 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 'lts/Iron' 31 | cache: 'yarn' 32 | 33 | - name: Install Dependencies 34 | run: yarn install --immutable --immutable-cache --check-cache 35 | 36 | - name: Mock aws-custom.json 37 | run: | 38 | cat > ./aws-custom.json << EOF 39 | { 40 | "healthScribeServiceRole": "arn:aws:iam::0123456789012:role/healthScribeServiceRole" 41 | } 42 | EOF 43 | 44 | - name: Mock amplifyconfiguration.json 45 | run: | 46 | cat > ./amplifyconfiguration.json << EOF 47 | { 48 | "aws_project_region": "us-east-1", 49 | "aws_user_files_s3_bucket": "s3bucket", 50 | } 51 | EOF 52 | 53 | - name: Type Check 54 | run: yarn run type-check 55 | -------------------------------------------------------------------------------- /.github/workflows/yarn-audit.yml: -------------------------------------------------------------------------------- 1 | name: Yarn Audit 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | type-check: 16 | name: Yarn Audit Checks 17 | runs-on: ubuntu-latest 18 | 19 | defaults: 20 | run: 21 | shell: bash 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 'lts/Hydrogen' 31 | cache: 'yarn' 32 | 33 | - name: Yarn Audit 34 | run: | 35 | FAILED=0 36 | 37 | for d in `find . -type f -name package.json ! -path "*/node_modules/*" ! -path "*/cdk.out/*" ! -path "*/#current-cloud-backend/*"`; do 38 | fileDir=$(dirname $d) 39 | 40 | if [ -f "${fileDir}/package-lock.json" ]; then 41 | echo "------------- ${d} -------------" 42 | npm --prefix `dirname $d` audit 43 | RESULT=${PIPESTATUS[0]} 44 | if [ $RESULT -ne 0 ]; then FAILED=1; fi 45 | 46 | elif [ -f "${fileDir}/yarn.lock" ]; then 47 | echo "------------- ${d} -------------" 48 | yarn --cwd `dirname $d` audit 2>&1 | grep -v 'No license field' 49 | RESULT=${PIPESTATUS[0]} 50 | if [ $RESULT -ne 0 ]; then FAILED=1; fi 51 | fi 52 | done 53 | 54 | if [ $FAILED -eq 1 ]; then exit 1; fi 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # Dependencies 11 | /node_modules 12 | /.pnp 13 | .pnp.js 14 | dist 15 | dist-ssr 16 | *.local 17 | 18 | # Testing 19 | /coverage 20 | 21 | # Production 22 | /build 23 | 24 | # Misc 25 | .DS_Store 26 | .env.local 27 | .env.development.local 28 | .env.test.local 29 | .env.production.local 30 | /*toolbag 31 | /.*Agent 32 | 33 | # VSCode 34 | .vscode 35 | 36 | # Build 37 | amplify.backup 38 | 39 | # GitLab 40 | .gitlab-ci.yml 41 | 42 | # Local Amplify deployment 43 | amplify/team-provider-info.json 44 | src/aws-custom.json 45 | 46 | # Webstorm 47 | .idea 48 | 49 | *.spec.md 50 | .cspell 51 | cspell.json 52 | 53 | #amplify-do-not-edit-begin 54 | amplify/\#current-cloud-backend 55 | amplify/.config/local-* 56 | amplify/logs 57 | amplify/mock-data 58 | amplify/mock-api-resources 59 | amplify/backend/amplify-meta.json 60 | amplify/backend/.temp 61 | build/ 62 | dist/ 63 | node_modules/ 64 | aws-exports.js 65 | awsconfiguration.json 66 | amplifyconfiguration.json 67 | amplifyconfiguration.dart 68 | amplify-build-config.json 69 | amplify-gradle-config.json 70 | amplifytools.xcconfig 71 | .secret-* 72 | **.sample 73 | #amplify-do-not-edit-end 74 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | node_modules/* -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | trailingComma: 'es5' 2 | tabWidth: 4 3 | singleQuote: true 4 | printWidth: 120 5 | plugins: 6 | - '@trivago/prettier-plugin-sort-imports' 7 | importOrder: 8 | - ^(^react$|react-dom$|^react-ace$) 9 | - ^react-router-dom$ 10 | - ^@cloudscape-design/(.*)$ 11 | - 12 | - ^@/(.*)$ 13 | - ^[./] 14 | importOrderSeparation: true 15 | importOrderSortSpecifiers: true 16 | importOrderGroupNamespaceSpecifiers: true -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## April 5, 2025 4 | 5 | - Add GIRPP note support 6 | - Switch to aws-amplify/storage 7 | - Replace lodash with native JS functions 8 | - Switch S3 upload to aws-amplify/storage 9 | - Update .gitignore with new patterns 10 | - Update changelog for future release 11 | - Simplify segment controls layout and styling for better visual consistency 12 | - Refactor HealthScribe data type for better readability 13 | 14 | ## October 28, 2024 15 | 16 | - Update README and dependencies 17 | 18 | ## August 9, 2024 19 | 20 | - Update UI layout 21 | 22 | ## June 30, 2024 23 | 24 | - Fix conversation duration display 25 | - Fix insights section ordering 26 | 27 | ## April 22, 2024 28 | 29 | - Tag S3 uploads with user ID 30 | - Enable Comprehend Medical for HealthScribe insights output 31 | - Enable pricing estimate for Comprehend Medical NERe 32 | 33 | ## April 1, 2024 34 | 35 | - Refactor Amplify JS to v6 36 | - Use HealthScribe SDK vs direct API calls 37 | - Move density to settings context 38 | - Use CloudScape design tokens 39 | 40 | ## March 18, 2024 41 | 42 | - Add GitHub actions for TypeScipt check 43 | 44 | ## March 16, 2024 45 | 46 | - Remove pre re:Invent 2023 output format 47 | 48 | ## March 10, 2024 49 | 50 | - Update README with available regions 51 | 52 | ## March 8, 2024 53 | 54 | - Set insight sort order 55 | - Set content type on S3 upload 56 | 57 | ## February 23, 2024 58 | 59 | - Show HealthScribe job duration 60 | 61 | ## February 14, 2024 62 | 63 | - Reformat insight plan section to show headers 64 | 65 | ## November 20, 2023 66 | 67 | - Update authentication logic 68 | - Add popover for live recording 69 | 70 | ## November 14, 2023 71 | 72 | - Add feature for live recording 73 | 74 | ## November 2, 2023 75 | 76 | - Enable Comprehend Medical ontology linking for HealthScribe transcript output 77 | - Use context for app settings, theme, auth, and notifications 78 | - Update API json output format 79 | 80 | ## September 13, 2023 81 | 82 | - Update silence calculation 83 | - Add JSON viewer for HealthScribe output 84 | 85 | ## August 31, 2023 86 | 87 | - Add audio generation with Amazon Polly 88 | 89 | ## August 23, 2023 90 | 91 | - Initial release 92 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | 3 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 4 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 5 | opensource-codeofconduct@amazon.com with any additional questions or comments. 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | ## Reporting Bugs/Feature Requests 10 | 11 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 12 | 13 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 14 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 15 | 16 | - A reproducible test case or series of steps 17 | - The version of our code being used 18 | - Any modifications you've made relevant to the bug 19 | - Anything unusual about your environment or deployment 20 | 21 | ## Contributing via Pull Requests 22 | 23 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 24 | 25 | 1. You are working against the latest source on the _main_ branch. 26 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 27 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 28 | 29 | To send us a pull request, please: 30 | 31 | 1. Fork the repository. 32 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 33 | 3. Ensure local tests pass. 34 | 4. Commit to your fork using clear commit messages. 35 | 5. Send us a pull request, answering any default questions in the pull request interface. 36 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 37 | 38 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 39 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 40 | 41 | ## Finding contributions to work on 42 | 43 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 44 | 45 | ## Code of Conduct 46 | 47 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 48 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 49 | opensource-codeofconduct@amazon.com with any additional questions or comments. 50 | 51 | ## Security issue notifications 52 | 53 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 54 | 55 | ## Licensing 56 | 57 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /amplify.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | backend: 3 | phases: 4 | build: 5 | commands: 6 | - amplifyPush --simple 7 | frontend: 8 | phases: 9 | preBuild: 10 | commands: 11 | - nvm use 20 12 | - yarn install 13 | build: 14 | commands: 15 | - yarn run build 16 | artifacts: 17 | baseDirectory: build 18 | files: 19 | - '**/*' 20 | cache: 21 | paths: 22 | - node_modules/**/* 23 | -------------------------------------------------------------------------------- /amplify/.config/project-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "providers": ["awscloudformation"], 3 | "projectName": "AwsHealthScribeDemo", 4 | "version": "3.1", 5 | "frontend": "javascript", 6 | "javascript": { 7 | "framework": "react", 8 | "config": { 9 | "SourceDir": "src", 10 | "DistributionDir": "build", 11 | "BuildCommand": "yarn run build", 12 | "StartCommand": "yarn run dev" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /amplify/backend/auth/healthScribeDemoAuth/cli-inputs.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1", 3 | "cognitoConfig": { 4 | "identityPoolName": "healthScribeDemoAuthIdentityPool", 5 | "allowUnauthenticatedIdentities": false, 6 | "resourceNameTruncated": "awsheaf9c98582", 7 | "userPoolName": "healthScribeDemoAuthUserPool", 8 | "autoVerifiedAttributes": ["email"], 9 | "mfaConfiguration": "OFF", 10 | "mfaTypes": ["SMS Text Message"], 11 | "smsAuthenticationMessage": "Your authentication code is {####}", 12 | "smsVerificationMessage": "Your verification code is {####}", 13 | "emailVerificationSubject": "Your AWS HealthScribe Demo Verification Code", 14 | "emailVerificationMessage": "Your verification code is {####}", 15 | "defaultPasswordPolicy": false, 16 | "passwordPolicyMinLength": 8, 17 | "passwordPolicyCharacters": [ 18 | "Requires Lowercase", 19 | "Requires Uppercase", 20 | "Requires Numbers", 21 | "Requires Symbols" 22 | ], 23 | "requiredAttributes": ["email"], 24 | "aliasAttributes": [], 25 | "userpoolClientGenerateSecret": false, 26 | "userpoolClientRefreshTokenValidity": "7", 27 | "userpoolClientWriteAttributes": ["email"], 28 | "userpoolClientReadAttributes": ["email"], 29 | "userpoolClientLambdaRole": "healthf9c98582_userpoolclient_lambda_role", 30 | "userpoolClientSetAttributes": false, 31 | "sharedId": "f9c98582", 32 | "resourceName": "healthScribeDemoAuth", 33 | "authSelections": "identityPoolAndUserPool", 34 | "useDefault": "manual", 35 | "thirdPartyAuth": false, 36 | "usernameAttributes": ["email"], 37 | "userPoolGroups": false, 38 | "adminQueries": false, 39 | "triggers": {}, 40 | "hostedUI": false, 41 | "userPoolGroupList": [], 42 | "serviceName": "Cognito", 43 | "usernameCaseSensitive": false, 44 | "useEnabledMfas": true 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /amplify/backend/auth/healthScribeDemoAuth/override.ts: -------------------------------------------------------------------------------- 1 | import { AmplifyAuthCognitoStackTemplate, AmplifyProjectInfo } from '@aws-amplify/cli-extensibility-helper'; 2 | 3 | export function override(resources: AmplifyAuthCognitoStackTemplate, amplifyProjectInfo: AmplifyProjectInfo) { 4 | // Shorten token validity periods 5 | resources.userPoolClient.refreshTokenValidity = 1; // days 6 | resources.userPoolClient.accessTokenValidity = 1; // hours 7 | resources.userPoolClient.idTokenValidity = 1; // hours 8 | 9 | resources.userPoolClientWeb.refreshTokenValidity = 1; // days 10 | resources.userPoolClientWeb.accessTokenValidity = 1; // hours 11 | resources.userPoolClientWeb.idTokenValidity = 1; // hours 12 | } 13 | -------------------------------------------------------------------------------- /amplify/backend/awscloudformation/override.ts: -------------------------------------------------------------------------------- 1 | import { AmplifyProjectInfo, AmplifyRootStackTemplate } from '@aws-amplify/cli-extensibility-helper'; 2 | 3 | export function override(resources: AmplifyRootStackTemplate, amplifyProjectInfo: AmplifyProjectInfo) { 4 | const authRole = resources.authRole; 5 | 6 | const basePolicies = Array.isArray(authRole.policies) ? authRole.policies : [authRole.policies]; 7 | 8 | /** 9 | * Allow authenticated users access to HealthScribe APIs 10 | * The ability for authenticated users to pass the role below to HealthScribe is added in the storage/S3 override 11 | */ 12 | authRole.policies = [ 13 | ...basePolicies, 14 | { 15 | policyName: 'aws-healthscribe', 16 | policyDocument: { 17 | Version: '2012-10-17', 18 | Statement: [ 19 | { 20 | Resource: '*', 21 | Action: [ 22 | 'transcribe:DeleteMedicalScribeJob', 23 | 'transcribe:ListMedicalScribeJobs', 24 | 'transcribe:GetMedicalScribeJob', 25 | 'transcribe:StartMedicalScribeJob', 26 | ], 27 | Effect: 'Allow', 28 | }, 29 | ], 30 | }, 31 | }, 32 | { 33 | policyName: 'supporting-services', 34 | policyDocument: { 35 | Version: '2012-10-17', 36 | Statement: [ 37 | { 38 | Resource: '*', 39 | Action: [ 40 | 'polly:SynthesizeSpeech', 41 | 'comprehendmedical:DetectEntitiesV2', 42 | 'comprehendmedical:InferICD10CM', 43 | 'comprehendmedical:InferRxNorm', 44 | 'comprehendmedical:InferSNOMEDCT', 45 | ], 46 | Effect: 'Allow', 47 | }, 48 | ], 49 | }, 50 | }, 51 | ]; 52 | } 53 | -------------------------------------------------------------------------------- /amplify/backend/backend-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth": { 3 | "healthScribeDemoAuth": { 4 | "customAuth": false, 5 | "dependsOn": [], 6 | "frontendAuthConfig": { 7 | "mfaConfiguration": "OFF", 8 | "mfaTypes": ["SMS"], 9 | "passwordProtectionSettings": { 10 | "passwordPolicyCharacters": [], 11 | "passwordPolicyMinLength": 8 12 | }, 13 | "signupAttributes": ["EMAIL"], 14 | "socialProviders": [], 15 | "usernameAttributes": ["EMAIL"], 16 | "verificationMechanisms": ["EMAIL"] 17 | }, 18 | "providerPlugin": "awscloudformation", 19 | "service": "Cognito" 20 | } 21 | }, 22 | "storage": { 23 | "healthScribeDemoStorage": { 24 | "dependsOn": [], 25 | "providerPlugin": "awscloudformation", 26 | "service": "S3" 27 | } 28 | }, 29 | "function": { 30 | "addBucketLogging": { 31 | "build": true, 32 | "providerPlugin": "awscloudformation", 33 | "service": "Lambda" 34 | } 35 | }, 36 | "parameters": { 37 | "AMPLIFY_function_addBucketLogging_deploymentBucketName": { 38 | "usedBy": [ 39 | { 40 | "category": "function", 41 | "resourceName": "addBucketLogging" 42 | } 43 | ] 44 | }, 45 | "AMPLIFY_function_addBucketLogging_s3Key": { 46 | "usedBy": [ 47 | { 48 | "category": "function", 49 | "resourceName": "addBucketLogging" 50 | } 51 | ] 52 | } 53 | }, 54 | "custom": { 55 | "addBucketLogging": { 56 | "dependsOn": [ 57 | { 58 | "attributes": ["BucketName", "Region", "HealthScribeServiceRoleArn"], 59 | "category": "storage", 60 | "resourceName": "healthScribeDemoStorage" 61 | }, 62 | { 63 | "attributes": ["Name", "Arn", "Region", "LambdaExecutionRole", "LambdaExecutionRoleArn"], 64 | "category": "function", 65 | "resourceName": "addBucketLogging" 66 | } 67 | ], 68 | "providerPlugin": "awscloudformation", 69 | "service": "customCDK" 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /amplify/backend/custom/addBucketLogging/.npmrc: -------------------------------------------------------------------------------- 1 | resolution-mode=highest 2 | -------------------------------------------------------------------------------- /amplify/backend/custom/addBucketLogging/cdk-stack.ts: -------------------------------------------------------------------------------- 1 | import * as AmplifyHelpers from '@aws-amplify/cli-extensibility-helper'; 2 | import * as cdk from 'aws-cdk-lib'; 3 | import { CfnOutput, CustomResource } from 'aws-cdk-lib'; 4 | import { BlockPublicAccess, Bucket, BucketEncryption } from 'aws-cdk-lib/aws-s3'; 5 | import { Construct } from 'constructs'; 6 | 7 | import { AmplifyDependentResourcesAttributes } from '../../types/amplify-dependent-resources-ref'; 8 | 9 | export class cdkStack extends cdk.Stack { 10 | constructor( 11 | scope: Construct, 12 | id: string, 13 | props?: cdk.StackProps, 14 | amplifyResourceProps?: AmplifyHelpers.AmplifyResourceProps 15 | ) { 16 | super(scope, id, props); 17 | /* Do not remove - Amplify CLI automatically injects the current deployment environment in this input parameter */ 18 | new cdk.CfnParameter(this, 'env', { 19 | type: 'String', 20 | description: 'Current Amplify CLI env name', 21 | }); 22 | 23 | // Get Amplify resources 24 | const amplifyResources: AmplifyDependentResourcesAttributes = AmplifyHelpers.addResourceDependency( 25 | this, 26 | amplifyResourceProps.category, 27 | amplifyResourceProps.resourceName, 28 | [ 29 | { category: 'storage', resourceName: 'healthScribeDemoStorage' }, 30 | { category: 'function', resourceName: 'addBucketLogging' }, 31 | ] 32 | ); 33 | const storageBucket = amplifyResources.storage.healthScribeDemoStorage.BucketName; 34 | const addBucketLoggingLambdaArn = amplifyResources.function.addBucketLogging.Arn; 35 | 36 | // Create a new logging bucket (SSL enforcement is applied in the custom resource) 37 | const loggingBucket = new Bucket(this, 'LoggingBucket', { 38 | blockPublicAccess: BlockPublicAccess.BLOCK_ALL, 39 | encryption: BucketEncryption.S3_MANAGED, 40 | removalPolicy: cdk.RemovalPolicy.RETAIN, 41 | }); 42 | 43 | // Invoke the Amplify-created Lambda function to add bucket policies and enable logging on the storage bucket 44 | new CustomResource(this, 'AddBucketLogging', { 45 | resourceType: 'Custom::AddBucketLogging', 46 | serviceToken: cdk.Fn.ref(addBucketLoggingLambdaArn), 47 | properties: { 48 | storageBucket: cdk.Fn.ref(storageBucket), 49 | loggingBucket: loggingBucket.bucketName, 50 | }, 51 | }); 52 | 53 | new CfnOutput(this, 'LoggingBucketName', { value: loggingBucket.bucketName }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /amplify/backend/custom/addBucketLogging/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-resource", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "build": "tsc", 7 | "watch": "tsc -w", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "dependencies": { 11 | "@aws-amplify/cli-extensibility-helper": "^3.0.37", 12 | "constructs": "^10.4.2" 13 | }, 14 | "devDependencies": { 15 | "typescript": "^5.8.3" 16 | }, 17 | "resolutions": { 18 | "aws-cdk-lib": "^2.188.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /amplify/backend/custom/addBucketLogging/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "strict": false, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "outDir": "build" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /amplify/backend/function/addBucketLogging/amplify.state: -------------------------------------------------------------------------------- 1 | { 2 | "pluginId": "amplify-nodejs-function-runtime-provider", 3 | "functionRuntime": "nodejs", 4 | "useLegacyBuild": true, 5 | "defaultEditorFile": "src/index.js" 6 | } -------------------------------------------------------------------------------- /amplify/backend/function/addBucketLogging/custom-policies.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Action": ["s3:PutBucketLogging"], 4 | "Resource": ["arn:aws:s3:::healthscribe-demo-storage*"] 5 | }, 6 | { 7 | "Action": ["s3:PutBucketPolicy"], 8 | "Resource": ["arn:aws:s3:::amplify*"] 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /amplify/backend/function/addBucketLogging/function-parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "lambdaLayers": [] 3 | } 4 | -------------------------------------------------------------------------------- /amplify/backend/function/addBucketLogging/src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | extends: ['eslint:recommended'], 4 | rules: { 5 | 'no-unused-vars': 'warn', 6 | }, 7 | }, 8 | ]; 9 | -------------------------------------------------------------------------------- /amplify/backend/function/addBucketLogging/src/cfn-reply.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | // Axios 5 | const axios = require('axios'); 6 | const axiosRetry = require('axios-retry').default; 7 | 8 | exports.SUCCESS = 'SUCCESS'; 9 | exports.FAILED = 'FAILED'; 10 | 11 | exports.send = async function (event, context, responseStatus, responseData, physicalResourceId, noEcho) { 12 | const responseBody = { 13 | Status: responseStatus, 14 | Reason: 'See the details in CloudWatch Log Stream: ' + context.logStreamName, 15 | PhysicalResourceId: physicalResourceId || context.logStreamName, 16 | StackId: event.StackId, 17 | RequestId: event.RequestId, 18 | LogicalResourceId: event.LogicalResourceId, 19 | NoEcho: noEcho || false, 20 | Data: responseData, 21 | }; 22 | 23 | console.log('Response body:\n', responseBody); 24 | 25 | axiosRetry(axios, { 26 | retries: 10, 27 | retryDelay: axiosRetry.exponentialDelay, 28 | }); 29 | 30 | try { 31 | const cfnResponse = await axios.put(event.ResponseURL, responseBody); 32 | console.debug('CloudFormation response:', cfnResponse); 33 | } catch (e) { 34 | console.error('Error sending status', e); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /amplify/backend/function/addBucketLogging/src/index.js: -------------------------------------------------------------------------------- 1 | const reply = require('./cfn-reply'); 2 | 3 | const { S3Client, PutBucketPolicyCommand, PutBucketLoggingCommand } = require('@aws-sdk/client-s3'); 4 | const s3Client = new S3Client(); 5 | 6 | const util = require('util'); 7 | 8 | /** 9 | * @description: Add bucket logging to the Amplify-created S3 bucket 10 | * @param {object} event CloudFormation event object, including S3 bucket names in ResourceProperties 11 | */ 12 | exports.handler = async (event, context) => { 13 | try { 14 | console.log('Event', util.inspect(event, false, null, false)); 15 | 16 | if (event.RequestType === 'Delete') { 17 | await reply.send(event, context, reply.SUCCESS, {}); 18 | return; 19 | } 20 | 21 | const storageBucket = event.ResourceProperties.storageBucket; 22 | const loggingBucket = event.ResourceProperties.loggingBucket; 23 | const accountIdFromCfRequest = event.StackId?.split(':')[4]; 24 | 25 | // Define logging bucket policy 26 | const loggingBucketPolicy = { 27 | Version: '2012-10-17', 28 | Statement: [ 29 | { 30 | Sid: 'AllowSSLRequestsOnly', 31 | Action: 's3:*', 32 | Effect: 'Deny', 33 | Principal: '*', 34 | Resource: [`arn:aws:s3:::${loggingBucket}`, `arn:aws:s3:::${loggingBucket}/*`], 35 | Condition: { 36 | Bool: { 37 | 'aws:SecureTransport': 'false', 38 | }, 39 | }, 40 | }, 41 | { 42 | Sid: 'S3ServerAccessLogsPolicy', 43 | Action: 's3:PutObject', 44 | Effect: 'Allow', 45 | Principal: { 46 | Service: 'logging.s3.amazonaws.com', 47 | }, 48 | Resource: `arn:aws:s3:::${loggingBucket}/s3-access-logs*`, 49 | Condition: { 50 | ArnLike: { 51 | 'aws:SourceArn': `arn:aws:s3:::${storageBucket}`, 52 | }, 53 | StringEquals: { 54 | 'aws:SourceAccount': accountIdFromCfRequest, 55 | }, 56 | }, 57 | }, 58 | ], 59 | }; 60 | 61 | // Add bucket logging policy to the logging bucket 62 | const putBucketPolicyInput = { 63 | Bucket: loggingBucket, 64 | Policy: JSON.stringify(loggingBucketPolicy), 65 | }; 66 | const putBucketPolicyCmd = new PutBucketPolicyCommand(putBucketPolicyInput); 67 | await s3Client.send(putBucketPolicyCmd); 68 | 69 | // Add bucket logging to the Amplify-created S3 bucket 70 | const putBucketLoggingInput = { 71 | Bucket: storageBucket, 72 | BucketLoggingStatus: { 73 | LoggingEnabled: { 74 | TargetBucket: loggingBucket, 75 | TargetPrefix: 's3-access-logs/', 76 | }, 77 | }, 78 | }; 79 | const putBucketLoggingCmd = new PutBucketLoggingCommand(putBucketLoggingInput); 80 | const putBucketLoggingRsp = await s3Client.send(putBucketLoggingCmd); 81 | 82 | await reply.send(event, context, reply.SUCCESS, putBucketLoggingRsp); 83 | } catch (e) { 84 | await reply.send(event, context, reply.FAILED, e); 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /amplify/backend/function/addBucketLogging/src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "addbucketlogging", 3 | "version": "2.0.0", 4 | "description": "Lambda function generated by Amplify", 5 | "main": "index.js", 6 | "license": "Apache-2.0", 7 | "dependencies": { 8 | "@aws-sdk/client-s3": "^3.782.0", 9 | "axios": "^1.8.4", 10 | "axios-retry": "^4.5.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /amplify/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "overrides-dependencies", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "build": "tsc", 7 | "watch": "tsc -w", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "dependencies": { 11 | "@aws-amplify/cli-extensibility-helper": "^3.0.37" 12 | }, 13 | "devDependencies": { 14 | "typescript": "^5.8.3" 15 | }, 16 | "resolutions": { 17 | "aws-cdk-lib": "^2.188.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /amplify/backend/storage/healthScribeDemoStorage/cli-inputs.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceName": "healthScribeDemoStorage", 3 | "policyUUID": "b7ee1e0f", 4 | "bucketName": "healthscribe-demo-storage", 5 | "storageAccess": "auth", 6 | "guestAccess": [], 7 | "authAccess": ["CREATE_AND_UPDATE", "READ", "DELETE"], 8 | "triggerFunction": "NONE", 9 | "groupAccess": {} 10 | } 11 | -------------------------------------------------------------------------------- /amplify/backend/storage/healthScribeDemoStorage/override.ts: -------------------------------------------------------------------------------- 1 | import { AmplifyProjectInfo, AmplifyS3ResourceTemplate } from '@aws-amplify/cli-extensibility-helper'; 2 | 3 | export function override(resources: AmplifyS3ResourceTemplate, amplifyProjectInfo: AmplifyProjectInfo) { 4 | /** 5 | * Override the default public poilcy to allow Put, Get, and Delete from the entire bucket 6 | * This is due to HealthScribe writing its results to the first level of the S3 bucket 7 | * All authenticated users can store and retrieve files in this bucket 8 | **/ 9 | resources.s3AuthPublicPolicy.policyDocument.Statement.push({ 10 | Effect: 'Allow', 11 | Action: ['s3:PutObject', 's3:PutObjectAcl', 's3:GetObject', 's3:PutObjectTagging'], 12 | Resource: `${resources.s3Bucket.attrArn}/*`, 13 | }); 14 | 15 | // Block all public access to the bucket 16 | resources.s3Bucket.publicAccessBlockConfiguration = { 17 | blockPublicAcls: true, 18 | blockPublicPolicy: true, 19 | ignorePublicAcls: true, 20 | restrictPublicBuckets: true, 21 | }; 22 | 23 | // Bucket logging is configured via the Lambda function addBucketLogging and Custom Resource addBucketLogging 24 | 25 | /** 26 | * Create a service role for HealthScribe jobs 27 | * This allows HealthScribe to access the Amplify-created S3 bucket 28 | * The role ARN is set as a CloudFormation output, adn is used by a post-push 29 | * Amplify hook to write to the frontend 30 | */ 31 | resources.addCfnResource( 32 | { 33 | type: 'AWS::IAM::Role', 34 | properties: { 35 | AssumeRolePolicyDocument: { 36 | Version: '2012-10-17', 37 | Statement: [ 38 | { 39 | Effect: 'Allow', 40 | Principal: { 41 | Service: ['transcribe.amazonaws.com'], 42 | }, 43 | Action: ['sts:AssumeRole'], 44 | }, 45 | ], 46 | }, 47 | Path: '/', 48 | Policies: [ 49 | { 50 | PolicyName: 'healthscribe-demo-service-policy', 51 | PolicyDocument: { 52 | Version: '2012-10-17', 53 | Statement: [ 54 | { 55 | Action: ['s3:GetObject', 's3:PutObject'], 56 | Resource: `${resources.s3Bucket.attrArn}/*`, 57 | Effect: 'Allow', 58 | }, 59 | { 60 | Action: 's3:ListBucket', 61 | Resource: `${resources.s3Bucket.attrArn}`, 62 | Effect: 'Allow', 63 | }, 64 | ], 65 | }, 66 | }, 67 | ], 68 | }, 69 | }, 70 | 'HealthScribeServiceRole' 71 | ); 72 | 73 | // Allow authenticated users to pass the role below to HealthScribe 74 | resources.addCfnResource( 75 | { 76 | type: 'AWS::IAM::Policy', 77 | properties: { 78 | PolicyName: 'healthscribe-service-passrole', 79 | PolicyDocument: { 80 | Version: '2012-10-17', 81 | Statement: [ 82 | { 83 | Resource: { 'Fn::GetAtt': ['HealthScribeServiceRole', 'Arn'] }, 84 | Action: ['iam:PassRole'], 85 | Effect: 'Allow', 86 | }, 87 | ], 88 | }, 89 | Roles: [ 90 | { 91 | Ref: 'authRoleName', 92 | }, 93 | ], 94 | }, 95 | }, 96 | 'HealthScribeServicePassRolePolicy' 97 | ); 98 | 99 | // Add service role ARN to the stack outputs. Used by Amplify post-push hook to write to the frontend 100 | resources.addCfnOutput( 101 | { 102 | value: { 'Fn::GetAtt': ['HealthScribeServiceRole', 'Arn'] } as unknown as string, 103 | }, 104 | 'HealthScribeServiceRoleArn' 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /amplify/backend/tags.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Key": "user:Stack", 4 | "Value": "{project-env}" 5 | }, 6 | { 7 | "Key": "user:Application", 8 | "Value": "{project-name}" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /amplify/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "strict": false, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "outDir": "build" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /amplify/backend/types/amplify-dependent-resources-ref.d.ts: -------------------------------------------------------------------------------- 1 | export type AmplifyDependentResourcesAttributes = { 2 | auth: { 3 | healthScribeDemoAuth: { 4 | AppClientID: 'string'; 5 | AppClientIDWeb: 'string'; 6 | IdentityPoolId: 'string'; 7 | IdentityPoolName: 'string'; 8 | UserPoolArn: 'string'; 9 | UserPoolId: 'string'; 10 | UserPoolName: 'string'; 11 | }; 12 | }; 13 | storage: { 14 | healthScribeDemoStorage: { 15 | BucketName: 'string'; 16 | HealthScribeServiceRoleArn: 'string'; 17 | Region: 'string'; 18 | }; 19 | }; 20 | function: { 21 | addBucketLogging: { 22 | Arn: 'string'; 23 | LambdaExecutionRole: 'string'; 24 | LambdaExecutionRoleArn: 'string'; 25 | Name: 'string'; 26 | Region: 'string'; 27 | }; 28 | }; 29 | custom: { 30 | addBucketLogging: { 31 | LoggingBucketName: 'string'; 32 | }; 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /amplify/cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "features": { 3 | "graphqltransformer": { 4 | "addmissingownerfields": true, 5 | "improvepluralization": false, 6 | "validatetypenamereservedwords": true, 7 | "useexperimentalpipelinedtransformer": true, 8 | "enableiterativegsiupdates": true, 9 | "secondarykeyasgsi": true, 10 | "skipoverridemutationinputtypes": true, 11 | "transformerversion": 2, 12 | "suppressschemamigrationprompt": true, 13 | "securityenhancementnotification": false, 14 | "showfieldauthnotification": false, 15 | "usesubusernamefordefaultidentityclaim": true, 16 | "usefieldnameforprimarykeyconnectionfield": false, 17 | "enableautoindexquerynames": true, 18 | "respectprimarykeyattributesonconnectionfield": true, 19 | "shoulddeepmergedirectiveconfigdefaults": false, 20 | "populateownerfieldforstaticgroupauth": true, 21 | "subscriptionsinheritprimaryauth": false, 22 | "enablegen2migration": false 23 | }, 24 | "frontend-ios": { 25 | "enablexcodeintegration": true 26 | }, 27 | "auth": { 28 | "enablecaseinsensitivity": true, 29 | "useinclusiveterminology": true, 30 | "breakcirculardependency": true, 31 | "forcealiasattributes": false, 32 | "useenabledmfas": true 33 | }, 34 | "codegen": { 35 | "useappsyncmodelgenplugin": true, 36 | "usedocsgeneratorplugin": true, 37 | "usetypesgeneratorplugin": true, 38 | "cleangeneratedmodelsdirectory": true, 39 | "retaincasestyle": true, 40 | "addtimestampfields": true, 41 | "handlelistnullabilitytransparently": true, 42 | "emitauthprovider": true, 43 | "generateindexrules": true, 44 | "enabledartnullsafety": true, 45 | "generatemodelsforlazyloadandcustomselectionset": false 46 | }, 47 | "appsync": { 48 | "generategraphqlpermissions": true 49 | }, 50 | "latestregionsupport": { 51 | "pinpoint": 1, 52 | "translate": 1, 53 | "transcribe": 1, 54 | "rekognition": 1, 55 | "textract": 1, 56 | "comprehend": 1 57 | }, 58 | "project": { 59 | "overrides": true 60 | } 61 | }, 62 | "debug": {} 63 | } 64 | -------------------------------------------------------------------------------- /amplify/hooks/post-push.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-require-imports 5 | const fs = require('fs'); 6 | 7 | const AMPLIFY_META_PATH = './amplify/backend/amplify-meta.json'; 8 | const AWS_CUSTOM_PATH = './src/aws-custom.json'; 9 | 10 | /** 11 | * @param data { { amplify: { environment: { envName: string, projectPath: string, defaultEditor: string }, command: string, subCommand: string, argv: string[] } } } 12 | * @param error { { message: string, stack: string } } 13 | */ 14 | const hookHandler = async () => { 15 | // Get Amplify Metadata 16 | const amplifyMeta = JSON.parse(fs.readFileSync(AMPLIFY_META_PATH, 'utf8')); 17 | // Get IAM role's ARN from the output in the Amplify root stack 18 | const healthScribeServiceRole = amplifyMeta.storage.healthScribeDemoStorage.output.HealthScribeServiceRoleArn; 19 | 20 | // Write the ARN to AMPLIFY_CUSTOM_PATH, which can be accessed by the frontend 21 | console.log(`Writing HealthScribe service role to ${AWS_CUSTOM_PATH}`); 22 | fs.writeFileSync( 23 | AWS_CUSTOM_PATH, 24 | JSON.stringify({ 25 | healthScribeServiceRole: healthScribeServiceRole, 26 | }) 27 | ); 28 | }; 29 | 30 | const getParameters = async () => { 31 | return JSON.parse(fs.readFileSync(0, { encoding: 'utf8' })); 32 | }; 33 | 34 | getParameters() 35 | .then((event) => hookHandler(event.data, event.error)) 36 | .catch((err) => { 37 | console.error(err); 38 | process.exitCode = 1; 39 | }); 40 | -------------------------------------------------------------------------------- /docs/amplify.md: -------------------------------------------------------------------------------- 1 | ## AWS Amplify Deployment Details 2 | 3 | - [AWS Amplify Deployment Details](#aws-amplify-deployment-details) 4 | - [Amplify Base](#amplify-base) 5 | - [Auth](#auth) 6 | - [Storage](#storage) 7 | - [Function](#function) 8 | - [Custom](#custom) 9 | - [Other](#other) 10 | 11 | All backend resources are deployed by AWS Amplify. This document describes where/how these resources are deployed. 12 | 13 | ### Amplify Base 14 | 15 | This stack creates: 16 | 17 | - A deployment S3 bucket to keep track of the Amplify deployment. 18 | 19 | - An auth and unauth role for Amazon Cognito Identity Pool 20 | 21 | This stack is overriden in [awscloudformation/override.ts](../amplify/backend/awscloudformation/override.ts) to: 22 | 23 | - Add HealthScribe IAM actions to the auth role. This allows authenticated users in the app to call HealthScribe. 24 | 25 | ### Auth 26 | 27 | This stack creates: 28 | 29 | - An Amazon Cognito user pool and identity pool. 30 | 31 | - The identity pool uses the auth and unauth roles from the base stack. In this webapp, unauth logins are not permitted. 32 | 33 | This stack is overriden in [auth/healthScribeDemo/auth/override.ts](../amplify/backend/auth/healthScribeDemoAuth/override.ts) to: 34 | 35 | - Reduce the timeframe for token validity periods: 36 | 37 | - Refresh token to 1 day 38 | 39 | - Access token to 1 hour 40 | 41 | - Identity token to 1 hour 42 | 43 | ### Storage 44 | 45 | This stack creates: 46 | 47 | - An S3 bucket used for audio files and HealthScribe output files. 48 | 49 | - IAM policies that allow authenticated users access to specific keys in this bucket. 50 | 51 | This stack is overriden in [storage/healthScribeDemoStorage/override.ts](../amplify/backend/storage/healthScribeDemoStorage/override.ts) to: 52 | 53 | - Create an IAM policy for the auth role to allow PutObject, PubObjectAcl, and GetObject. This allows any authenticated user to upload files and download HealthScribe output json. 54 | 55 | - Block public access for the Amplify-created bucket. 56 | 57 | - Create a private S3 bucket to store access logs from the Amplify-created storage S3 bucket. 58 | 59 | - Create an IAM service role for `transcribe.amazon.com` to assume, that allows s3:GetObject, s3:PubObject, and s3:ListBucket on the Amplify-created bucket. This role is assumed by HealthScribe to read the audio file and write the results. 60 | 61 | - Create an IAM PassRole policy for the auth role to allow authenticated users to pass the service role to HealthScribe. 62 | 63 | ### Function 64 | 65 | This stack creates: 66 | 67 | - A Node.js 18 Lambda function that is meant to be used as a custom resource. It expects two properties: the names of the storage bucket and logging bucket. The Lambda function turns on logging on the storage bucket and the destination is `loggingBucket/s3-access-logs/`. 68 | 69 | This is done because the AWS-managed AdministratorAccess-Amplify role does not have s3:PutBucketLogging permissions. 70 | 71 | ### Custom 72 | 73 | This custom CDK resource creates: 74 | 75 | - An S3 bucket for S3 access logs and a least-privilege bucket policy. This bucket receives access logs for the Amplify-created storage bucket. 76 | 77 | - A custom resource to invoke the Lambda function above to turn on access logging for the storage S3 bucket. 78 | 79 | ### Other 80 | 81 | The order of deployment matters in this case. As of Aug 22, 2023, Amplify deploys resources based on the order in [backend-config.json](../amplify/backend/backend-config.json). 82 | -------------------------------------------------------------------------------- /docs/deploy.md: -------------------------------------------------------------------------------- 1 | ## Semi-Automatic Deployment 2 | 3 | This deployment method uses the [AWS Amplify](https://aws.amazon.com/amplify/) console to deploy the web app via [AWS CodeCommit](https://aws.amazon.com/codecommit/). 4 | 5 | For a more automated deployment, see the relevant section in [the readme](../README.md#automatic-deployment). 6 | 7 | ### Steps 8 | 9 | #### Populate AWS CodeCommit 10 | 11 | For other ways of commiting code to AWS CodeCommit, refer to the [Setting up](https://docs.aws.amazon.com/codecommit/latest/userguide/setting-up.html) section in the reference guide. 12 | 13 | - Download the repository as a `.zip` file. 14 | 15 | - In GitHub, select `Code`, then `Download ZIP`. 16 | - In GitLab, select the download icon, then `zip`. 17 | - Make a note of the file name (it should be `aws-healthscribe-demo-main.zip`). 18 | 19 | - Navigate to the [AWS console for AWS CodeCommit](https://console.aws.amazon.com/codesuite/codecommit/home). 20 | 21 | - Select _Create repository_. 22 | 23 | - Name the repository `aws-healthscribe-demo`, and select _Create_. 24 | 25 | - At the top of the AWS console, select the AWS CloudShell icon. This is to the right of the search bar, and left of the notifications icon. 26 | 27 | - After CloudShell has loaded, configure git with your email, name, and changing the default branch name to main. 28 | 29 | ``` 30 | git config --global user.email "" 31 | git config --global user.name "" 32 | git config --global init.defaultBranch main 33 | ``` 34 | 35 | 36 | 37 | - In the _Actions_ dropdown, then _Upload file_. Select the zip file you downloaded in step 1. 38 | 39 | - Unzip the file to the repository: `unzip `. E.g. `unzip aws-healthscribe-demo-main.zip`. This extracts the repository into a directory called `aws-healthscribe-demo-main`. 40 | 41 | - Within the repository directory, initialize git: `cd aws-healthscribe-demo-main; git init` 42 | 43 | - In the AWS console for CodeCommit, select _Clone URL_, then _Clone HTTPS (GRC)_. This copies a CodeCommit URL similiar to `codecommit::us-east-1://aws-healthscribe-demo` to your cliipboard. 44 | 45 | - Add the CodeCommit repository: `git remote add origin `, i.e. `git remote add origin codecommit::us-east-1://aws-healthscribe-demo`. 46 | 47 | - Push the files to CodeCommit: `git add -A; git commit -m "Initial commit"; git push -u origin main` 48 | 49 | - Close AWS CloudShell. 50 | 51 | #### Deploy using AWS Amplify 52 | 53 | - Navigate to the [AWS console for AWS Amplify](https://console.aws.amazon.com/amplify/home). 54 | 55 | - Select _New app_, then _Host web app_. 56 | 57 | - In the _Get started with Amplify Hosting_ page, select _AWS CodeCommit_, then select _Continue_. 58 | 59 | - Select the AWS CodeCommit repository you created earlier (`aws-healthscribe-demo`), and verify that the branch name is correct (`main`). Select _Next_. 60 | 61 | - Feel free to change the app name on the _Build settings_ screen. 62 | 63 | - In the _Environment_ dropdown, select _Create new environment_. 64 | 65 | - In the _Service Role_ dropdown, select an existing role if have an existing service role for Amplify. Otherwise select the _Create new role_ button and follow the prompts. After creating the new IAM role, select the refresh button next to the dropdown and select your newly created IAM role. 66 | 67 | - Verify `amplify.yml` is detected in the _Build and test settings_ section. 68 | 69 | - Review the app settings, and select _Save and deploy_ 70 | 71 | - If a build fails, you can review the logs from the AWS Amplify console. You can also attempt a rebuild by selecting the failed build, then selecting the _Redeploy this version_ button. 72 | 73 | _Note:_ if the error says Amplify app ID not found, modify the build service IAM role to include the _AdministratorAccess-Amplify_ AWS-managed policy. You can find the build service IAM role by selecting _General_ in the Amplify app console. 74 | 75 | - The web app URL is named `https://..amplifyapp.com` and can be found in the **Hosting environments** tab 76 | -------------------------------------------------------------------------------- /images/AWS-HealthScribe-Demo-Architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-healthscribe-demo/ca45e839a1bab66f4b7f436bf05331e2df4da025/images/AWS-HealthScribe-Demo-Architecture.png -------------------------------------------------------------------------------- /images/UI-Sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-healthscribe-demo/ca45e839a1bab66f4b7f436bf05331e2df4da025/images/UI-Sample.gif -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | AWS HealthScribe Demo 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-healthscribe-demo", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build", 7 | "serve": "vite preview", 8 | "lint:fix": "eslint ./src --ext .jsx,.js,.ts,.tsx --format visualstudio --quiet --fix --ignore-path ./.gitignore", 9 | "lint:format": "prettier --log-level warn --write \"./**/*.{js,jsx,ts,tsx,css,md,json}\" ", 10 | "lint": "yarn lint:format && yarn lint:fix ", 11 | "type-check": "tsc" 12 | }, 13 | "dependencies": { 14 | "@aws-amplify/ui-react": "^6.10.0", 15 | "@aws-sdk/client-comprehendmedical": "^3.782.0", 16 | "@aws-sdk/client-polly": "^3.782.0", 17 | "@aws-sdk/client-s3": "^3.782.0", 18 | "@aws-sdk/client-transcribe": "^3.782.0", 19 | "@aws-sdk/lib-storage": "^3.782.0", 20 | "@aws-sdk/s3-request-presigner": "^3.782.0", 21 | "@cloudscape-design/collection-hooks": "^1.0.68", 22 | "@cloudscape-design/components": "^3.0.938", 23 | "@cloudscape-design/design-tokens": "^3.0.53", 24 | "@cloudscape-design/global-styles": "^1.0.39", 25 | "ace-builds": "^1.39.1", 26 | "aws-amplify": "^6.14.1", 27 | "crunker": "^2.4.0", 28 | "dayjs": "^1.11.13", 29 | "motion": "^12.6.3", 30 | "react": "^19.1.0", 31 | "react-ace": "^14.0.1", 32 | "react-dom": "^19.1.0", 33 | "react-dropzone": "^14.3.8", 34 | "react-hot-toast": "^2.5.2", 35 | "react-router-dom": "^7.5.0", 36 | "timekeeper": "^2.3.1", 37 | "use-debounce": "^10.0.4", 38 | "uuid4": "^2.0.3", 39 | "wavesurfer.js": "7.9.4" 40 | }, 41 | "devDependencies": { 42 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 43 | "@types/node": "^22.14.0", 44 | "@types/react": "^19.1.0", 45 | "@types/react-dom": "^19.1.1", 46 | "@types/uuid4": "^2.0.3", 47 | "@types/wavesurfer.js": "^6.0.12", 48 | "@typescript-eslint/eslint-plugin": "^8.29.0", 49 | "@typescript-eslint/parser": "^8.29.0", 50 | "@vitejs/plugin-react": "^4.3.4", 51 | "eslint": "^8.57.1", 52 | "eslint-config-prettier": "^10.1.1", 53 | "eslint-plugin-prettier": "^5.2.6", 54 | "eslint-plugin-react": "^7.37.5", 55 | "pre-commit": "^1.2.2", 56 | "prettier": "^3.5.3", 57 | "typescript": "^5.8.3", 58 | "vite": "^6.2.5", 59 | "vite-plugin-optimize-css-modules": "^1.2.0" 60 | }, 61 | "resolutions": { 62 | "cross-spawn": "^7.0.6" 63 | }, 64 | "pre-commit": "lint", 65 | "license": "MIT", 66 | "packageManager": "yarn@1.22.22" 67 | } 68 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 31 | 32 | 34 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /public/record.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-healthscribe-demo/ca45e839a1bab66f4b7f436bf05331e2df4da025/public/record.png -------------------------------------------------------------------------------- /src/Globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.css'; 2 | -------------------------------------------------------------------------------- /src/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import React, { Suspense, lazy } from 'react'; 4 | 5 | import { Navigate, Route, Routes } from 'react-router-dom'; 6 | 7 | import AppLayout from '@cloudscape-design/components/app-layout'; 8 | import Flashbar from '@cloudscape-design/components/flashbar'; 9 | 10 | import Breadcrumbs from '@/components/Breadcrumbs'; 11 | import SideNav from '@/components/SideNav'; 12 | import SuspenseLoader from '@/components/SuspenseLoader'; 13 | import TopNav from '@/components/TopNav'; 14 | import Welcome from '@/components/Welcome'; 15 | import { useAuthContext } from '@/store/auth'; 16 | import { useNotificationsContext } from '@/store/notifications'; 17 | 18 | // Lazy components 19 | const Debug = lazy(() => import('@/components/Debug')); 20 | const Settings = lazy(() => import('@/components/Settings')); 21 | const Conversations = lazy(() => import('@/components/Conversations')); 22 | const Conversation = lazy(() => import('@/components/Conversation')); 23 | const NewConversation = lazy(() => import('@/components/NewConversation')); 24 | const GenerateAudio = lazy(() => import('@/components/GenerateAudio')); 25 | 26 | export default function App() { 27 | const { isUserAuthenticated } = useAuthContext(); 28 | const { flashbarItems } = useNotificationsContext(); 29 | 30 | const content = ( 31 | }> 32 | {isUserAuthenticated ? ( 33 | 34 | } /> 35 | } /> 36 | } /> 37 | } /> 38 | } /> 39 | } /> 40 | } /> 41 | } /> 42 | 43 | ) : ( 44 | 45 | } /> 46 | 47 | )} 48 | 49 | ); 50 | 51 | return ( 52 | <> 53 |
54 | 55 |
56 | } 58 | content={content} 59 | headerSelector="#appTopNav" 60 | headerVariant="high-contrast" 61 | navigation={} 62 | navigationHide={!isUserAuthenticated} 63 | notifications={} 64 | toolsHide={true} 65 | /> 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/App/index.ts: -------------------------------------------------------------------------------- 1 | export { default as App } from './App'; 2 | -------------------------------------------------------------------------------- /src/components/Auth/Auth.tsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | // This is a modal authentication component that displays the AWS Amplify Authenticator. 4 | import React, { useMemo } from 'react'; 5 | 6 | import * as awsui from '@cloudscape-design/design-tokens'; 7 | import Box from '@cloudscape-design/components/box'; 8 | import Button from '@cloudscape-design/components/button'; 9 | import Modal from '@cloudscape-design/components/modal'; 10 | import SpaceBetween from '@cloudscape-design/components/space-between'; 11 | 12 | import { Authenticator, Theme, ThemeProvider, defaultDarkModeOverride } from '@aws-amplify/ui-react'; 13 | import '@aws-amplify/ui-react/styles.css'; 14 | 15 | import { useAppThemeContext } from '@/store/appTheme'; 16 | 17 | const authUiComponents = { 18 | SignUp: { 19 | Header() { 20 | return ( 21 |
28 | A verification code will be sent to your email address to validate the account. 29 |
30 | ); 31 | }, 32 | }, 33 | }; 34 | 35 | type AuthParams = { 36 | setVisible: (visible: boolean) => void; 37 | }; 38 | 39 | export default function Auth({ setVisible }: AuthParams) { 40 | const { appTheme } = useAppThemeContext(); 41 | 42 | /** 43 | * Amplify-UI's uses 80 for the button, 90 for hover 44 | * Override to Cloudscape colors - https://cloudscape.design/foundation/visual-foundation/colors/ 45 | */ 46 | const amplifyTheme: Theme = { 47 | name: 'AuthTheme', 48 | overrides: [defaultDarkModeOverride], 49 | tokens: { 50 | colors: { 51 | primary: { 52 | 80: awsui.colorBackgroundButtonPrimaryActive, 53 | 90: awsui.colorBackgroundButtonPrimaryDefault, 54 | 100: awsui.colorBackgroundButtonPrimaryActive, 55 | }, 56 | }, 57 | components: { 58 | tabs: { 59 | item: { 60 | _hover: { 61 | color: { 62 | value: awsui.colorBackgroundButtonPrimaryDefault, 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | }, 69 | }; 70 | 71 | const colorMode = useMemo(() => { 72 | if (appTheme.color === 'appTheme.light') { 73 | return 'light'; 74 | } else if (appTheme.color === 'appTheme.dark') { 75 | return 'dark'; 76 | } else { 77 | return 'system'; 78 | } 79 | }, [appTheme]); 80 | 81 | return ( 82 | setVisible(false)} 84 | visible={true} 85 | footer={ 86 | 87 | 88 | 91 | 92 | 93 | } 94 | > 95 | 96 | 97 | 98 | You will be redirected shortly. 99 | 100 | 101 | 102 | 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/components/Auth/index.ts: -------------------------------------------------------------------------------- 1 | import Auth from './Auth'; 2 | 3 | export default Auth; 4 | -------------------------------------------------------------------------------- /src/components/Breadcrumbs/Breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import React, { useLocation, useNavigate } from 'react-router-dom'; 4 | 5 | import BreadcrumbGroup, { BreadcrumbGroupProps } from '@cloudscape-design/components/breadcrumb-group'; 6 | 7 | import { useAuthContext } from '@/store/auth'; 8 | 9 | export default function Breadcrumbs() { 10 | const { isUserAuthenticated } = useAuthContext(); 11 | 12 | const location = useLocation(); 13 | const navigate = useNavigate(); 14 | let items: BreadcrumbGroupProps.Item[] = []; 15 | 16 | const baseBreadcrumb = [ 17 | { 18 | text: 'Home', 19 | href: '/', 20 | }, 21 | ]; 22 | 23 | const pathName = location.pathname; 24 | 25 | if (pathName === '/settings') { 26 | items = [ 27 | ...baseBreadcrumb, 28 | { 29 | text: 'Settings', 30 | href: '/settings', 31 | }, 32 | ]; 33 | } else if (pathName === '/conversations') { 34 | items = [ 35 | ...baseBreadcrumb, 36 | { 37 | text: 'Conversations', 38 | href: '/conversations', 39 | }, 40 | ]; 41 | } else if (pathName.startsWith('/conversation/')) { 42 | const conversationName = pathName.split('/')[2]; 43 | items = [ 44 | ...baseBreadcrumb, 45 | { 46 | text: 'Conversations', 47 | href: '/conversations', 48 | }, 49 | { 50 | text: conversationName, 51 | href: `/conversations/${conversationName}`, 52 | }, 53 | ]; 54 | } else if (pathName === '/new') { 55 | items = [ 56 | ...baseBreadcrumb, 57 | { 58 | text: 'New Conversation', 59 | href: '/new', 60 | }, 61 | ]; 62 | } else if (pathName === '/generate') { 63 | items = [ 64 | ...baseBreadcrumb, 65 | { 66 | text: 'Generate Audio', 67 | href: '/generate', 68 | }, 69 | ]; 70 | } 71 | 72 | if (isUserAuthenticated) { 73 | return ( 74 | { 77 | event.preventDefault(); 78 | navigate(event.detail.href, { relative: 'route' }); 79 | }} 80 | /> 81 | ); 82 | } else { 83 | return false; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/components/Breadcrumbs/index.ts: -------------------------------------------------------------------------------- 1 | import Breadcrumbs from './Breadcrumbs'; 2 | 3 | export default Breadcrumbs; 4 | -------------------------------------------------------------------------------- /src/components/Common/AudioControls.module.css: -------------------------------------------------------------------------------- 1 | .ctrlHeading { 2 | padding: 0 !important; 3 | margin-bottom: 15px !important; 4 | } 5 | 6 | .playerControl { 7 | position: fixed; 8 | bottom: 1%; 9 | right: 1%; 10 | z-index: 100; 11 | } 12 | 13 | .playerControlInline { 14 | display: flex; 15 | align-items: center; 16 | gap: 8px; 17 | flex-wrap: wrap; 18 | } 19 | 20 | .closeButton { 21 | position: absolute !important; 22 | top: 5px; 23 | right: 5px; 24 | } 25 | 26 | .downloadButton { 27 | background: var(--color-background-button-normal-default-klhbuw, #ffffff); 28 | color: var(--color-text-button-normal-default-mo7k6u, #0972d3); 29 | font-weight: var(--font-button-weight-hv56tz, 700); 30 | border-radius: var(--border-radius-button-ypmfry, 20px); 31 | border: var(--border-field-width-09w7vk, 2px) solid; 32 | padding: var(--space-scaled-xxs-7597g1, 4px) var(--space-button-horizontal-8jxzea, 20px); 33 | text-decoration: none; 34 | cursor: pointer; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Common/ValueWithLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Box from '@cloudscape-design/components/box'; 4 | 5 | type ValueWithLabelProps = { 6 | label: string; 7 | children: string | React.ReactNode; 8 | }; 9 | export default function ValueWithLabel({ label, children }: ValueWithLabelProps) { 10 | return ( 11 |
12 | {label} 13 |
{children}
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Conversation/Common/ComprehendMedical.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import Box from '@cloudscape-design/components/box'; 6 | import Link from '@cloudscape-design/components/link'; 7 | import Popover from '@cloudscape-design/components/popover'; 8 | import StatusIndicator from '@cloudscape-design/components/status-indicator'; 9 | 10 | import ValueWithLabel from '@/components/Common/ValueWithLabel'; 11 | import { useAppSettingsContext } from '@/store/appSettings'; 12 | 13 | export function EnableComprehendMedicalPopover() { 14 | const { appSettings } = useAppSettingsContext(); 15 | const navigate = useNavigate(); 16 | 17 | const comprehendMedicalEnabled = useMemo(() => appSettings['app.comprehendMedicalEnabled'], [appSettings]); 18 | 19 | if (!comprehendMedicalEnabled) { 20 | return ( 21 | 25 |

26 | 27 | Amazon Comprehend Medical 28 | {' '} 29 | is a healthcare natural language processing service (NLP) that uses machine learning to 30 | identify and extract information from medical text. 31 |

32 |

33 | { 36 | e.preventDefault(); 37 | navigate('/settings'); 38 | }} 39 | > 40 | Enable Amazon Comprehend Medical 41 | {' '} 42 | in the app settings to use this feature. 43 |

44 | 45 | } 46 | > 47 | 48 |
49 | ); 50 | } else { 51 | return false; 52 | } 53 | } 54 | 55 | type ComprehendMedicalNereCostProps = { 56 | clinicalDocumentNereUnits: 0 | { [key: string]: number }; 57 | }; 58 | export function ComprehendMedicalNereCost({ clinicalDocumentNereUnits }: ComprehendMedicalNereCostProps) { 59 | const { appSettings } = useAppSettingsContext(); 60 | const comprehendMedicalEnabled = useMemo(() => appSettings['app.comprehendMedicalEnabled'], [appSettings]); 61 | 62 | if (comprehendMedicalEnabled && clinicalDocumentNereUnits !== 0) { 63 | return ( 64 | 68 | 69 | ${(clinicalDocumentNereUnits.eachSegment * 0.01).toFixed(2)} 70 | 71 |

72 | This demo uses the Medical Named Entity and Relationship Extraction (NERe) functionality of{' '} 73 | 74 | Amazon Comprehend Medical 75 | {' '} 76 | to extract data one insight at a time. 77 |

78 |

79 | The estimated cost for calling the NERe API for each section is{' '} 80 | ${(clinicalDocumentNereUnits.eachSection * 0.01).toFixed(2)}, and for the entire 81 | summary, ${(clinicalDocumentNereUnits.allAtOnce * 0.01).toFixed(2)}. 82 |

83 |

84 | Refer to the{' '} 85 | 86 | Amazon Comprehend Medical pricing page 87 | {' '} 88 | to learn more about pricing tiers, including free tier.{' '} 89 |

90 | 91 | } 92 | > 93 | 94 |
95 | ); 96 | } else { 97 | return false; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/components/Conversation/Common/LoadingContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Container from '@cloudscape-design/components/container'; 4 | import Header from '@cloudscape-design/components/header'; 5 | import Spinner from '@cloudscape-design/components/spinner'; 6 | 7 | import styles from './ScrollingContainer.module.css'; 8 | 9 | type LoadingContainerProps = { 10 | containerTitle: string; 11 | text?: string; 12 | }; 13 | export default function LoadingContainer({ containerTitle, text }: LoadingContainerProps) { 14 | return ( 15 | {containerTitle}}> 16 |
17 | {text} 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Conversation/Common/OntologyLinking.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import Button from '@cloudscape-design/components/button'; 4 | 5 | import { InferICD10CMResponse, InferRxNormResponse, InferSNOMEDCTResponse } from '@aws-sdk/client-comprehendmedical'; 6 | 7 | import { useAppSettingsContext } from '@/store/appSettings'; 8 | import { getInferredData } from '@/utils/ComprehendMedicalApi'; 9 | 10 | import { EnableComprehendMedicalPopover } from '../Common/ComprehendMedical'; 11 | import OntologyLinkingData from './OntologyLinkingData'; 12 | 13 | // Comprehend Medical ontology linking API qualification categories and types 14 | const ONTOLOGY_LINKING = [ 15 | { key: 'icd10cm', name: 'ICD-10-CM', category: ['MEDICAL_CONDITION'], type: ['DX_NAME', 'TIME_EXPRESSION'] }, 16 | { key: 'rxnorm', name: 'RxNorm', category: ['MEDICATION'] }, 17 | { key: 'snomedct', name: 'SNOMED CT', category: ['MEDICAL_CONDITION', 'ANATOMY', 'TEST_TREATMENT_PROCEDURE'] }, 18 | ]; 19 | 20 | export type InferredDataType = { 21 | [key: string]: false | 'loading' | InferICD10CMResponse | InferRxNormResponse | InferSNOMEDCTResponse; 22 | }; 23 | 24 | type OntologyLinkingProps = { 25 | category: string; 26 | type: string; 27 | text: string; 28 | }; 29 | 30 | export function OntologyLinking({ category, type, text }: OntologyLinkingProps) { 31 | const { appSettings } = useAppSettingsContext(); 32 | const comprehendMedicalEnabled = useMemo(() => appSettings['app.comprehendMedicalEnabled'], [appSettings]); 33 | 34 | const [inferredData, setInferredData] = React.useState({ 35 | icd10cm: false, 36 | rxnorm: false, 37 | snomedct: false, 38 | }); 39 | 40 | async function handleInfer(ontologyType: string, text: string) { 41 | setInferredData({ ...inferredData, [ontologyType]: 'loading' }); 42 | const comprehendMedicalResult = await getInferredData(ontologyType, text); 43 | setInferredData({ ...inferredData, [ontologyType]: comprehendMedicalResult }); 44 | } 45 | 46 | return ( 47 | <> 48 |
51 | {ONTOLOGY_LINKING.map((o, index) => { 52 | if (o.category.includes(category)) { 53 | if (!!o.type && !o.type.includes(type)) return; 54 | return ( 55 | 61 | ); 62 | } 63 | })} 64 | 65 |
66 | 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/components/Conversation/Common/OntologyLinkingData.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Box from '@cloudscape-design/components/box'; 4 | import ExpandableSection from '@cloudscape-design/components/expandable-section'; 5 | import SpaceBetween from '@cloudscape-design/components/space-between'; 6 | import Table from '@cloudscape-design/components/table'; 7 | 8 | import { 9 | ICD10CMConcept, 10 | InferICD10CMResponse, 11 | InferRxNormResponse, 12 | InferSNOMEDCTResponse, 13 | } from '@aws-sdk/client-comprehendmedical'; 14 | 15 | import getPercentageFromDecimal from '@/utils/getPercentageFromDecimal'; 16 | 17 | import { InferredDataType } from './OntologyLinking'; 18 | 19 | type InferredDataProps = { 20 | items: ICD10CMConcept[] | undefined; 21 | }; 22 | 23 | function OntologyTable({ items }: InferredDataProps) { 24 | if (typeof items === 'undefined') return null; 25 | return ( 26 | item.Code, 32 | sortingField: 'code', 33 | isRowHeader: true, 34 | }, 35 | { 36 | id: 'description', 37 | header: 'Description', 38 | cell: (item) => item.Description, 39 | sortingField: 'description', 40 | }, 41 | { 42 | id: 'score', 43 | header: 'Score', 44 | cell: (item) => getPercentageFromDecimal(item.Score), 45 | sortingField: 'score', 46 | }, 47 | ]} 48 | items={items} 49 | sortingDisabled={true} 50 | loadingText="Loading items" 51 | empty={ 52 | 53 | 54 | No items 55 | 56 | 57 | } 58 | variant={'embedded'} 59 | /> 60 | ); 61 | } 62 | 63 | type ClinicalInsightsInferredDataProps = { inferredData: InferredDataType }; 64 | // Function to display an expandable selection with data from Comprehend Medical 65 | export default function OntologyLinkingData({ inferredData }: ClinicalInsightsInferredDataProps) { 66 | return ( 67 |
68 | {typeof inferredData.icd10cm === 'object' && ( 69 | 70 | 73 | 74 |
75 | Score: {getPercentageFromDecimal(inferredData.icd10cm?.Entities?.[0]?.Score)} 76 |
77 |
78 |
79 | )} 80 | {typeof inferredData.rxnorm === 'object' && ( 81 | 82 | 83 | 84 |
85 | Score: {getPercentageFromDecimal(inferredData.rxnorm?.Entities?.[0]?.Score)} 86 |
87 |
88 |
89 | )} 90 | {typeof inferredData.snomedct === 'object' && ( 91 | 92 | 95 | 96 |
97 | Score: {getPercentageFromDecimal(inferredData.snomedct?.Entities?.[0]?.Score)} 98 |
99 |
100 |
101 | )} 102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/components/Conversation/Common/ScrollingContainer.module.css: -------------------------------------------------------------------------------- 1 | .childDiv { 2 | min-height: 300px; 3 | height: calc(100vh - 400px); 4 | margin-bottom: 15px; 5 | overflow: scroll; 6 | -ms-overflow-style: none; /* IE and Edge */ 7 | scrollbar-width: none; /* mozilla */ 8 | } 9 | 10 | .childDiv::-webkit-scrollbar { 11 | display: none; /* Safari and Chrome browsers */ 12 | } 13 | 14 | .scrollUpIcon { 15 | position: absolute; 16 | top: 5px; 17 | left: 50%; 18 | opacity: 0.5; 19 | animation: jumpTop 1.5s infinite; 20 | } 21 | 22 | .scrollDownIcon { 23 | position: absolute; 24 | bottom: 0; 25 | left: 50%; 26 | opacity: 0.5; 27 | animation: jumpBottom 1.5s infinite; 28 | } 29 | 30 | @keyframes jumpTop { 31 | 0% { 32 | margin-top: 0; 33 | } 34 | 50% { 35 | margin-top: 10px; 36 | } 37 | 100% { 38 | margin-top: 0; 39 | } 40 | } 41 | 42 | @keyframes jumpBottom { 43 | 0% { 44 | margin-top: 0; 45 | } 46 | 50% { 47 | margin-bottom: 10px; 48 | } 49 | 100% { 50 | margin-top: 0; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Conversation/Common/ScrollingContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | 3 | import Container from '@cloudscape-design/components/container'; 4 | import Header from '@cloudscape-design/components/header'; 5 | import Icon from '@cloudscape-design/components/icon'; 6 | 7 | import { useDebouncedCallback } from 'use-debounce'; 8 | 9 | import { useScroll } from '@/hooks/useScroll'; 10 | 11 | import styles from './ScrollingContainer.module.css'; 12 | 13 | type ScrollingContainerProps = { 14 | containerTitle: string; 15 | containerActions?: React.ReactNode; 16 | children: React.ReactNode; 17 | }; 18 | export default function ScrollingContainer({ 19 | containerTitle, 20 | containerActions = null, 21 | children, 22 | }: ScrollingContainerProps) { 23 | const [showUpScroll, setShowUpScroll] = useState(false); 24 | const [showDownScroll, setShowDownScroll] = useState(false); 25 | 26 | // Use a ref for the right panel container, so we can show arrows for scrolling 27 | const childContainerRef = useRef(null); 28 | function handleScroll(e: Event) { 29 | const scrollElement = e.target as HTMLElement; 30 | const scrollLeftTop = scrollElement.scrollTop > 0; 31 | if (scrollLeftTop) { 32 | setShowUpScroll(true); 33 | } else { 34 | setShowUpScroll(false); 35 | } 36 | const scrollAtBottom = scrollElement.scrollHeight - scrollElement.scrollTop === scrollElement.clientHeight; 37 | if (scrollAtBottom) { 38 | setShowDownScroll(false); 39 | } else { 40 | setShowDownScroll(true); 41 | } 42 | } 43 | const debouncedHandleScroll = useDebouncedCallback(handleScroll, 300); 44 | useScroll(childContainerRef, debouncedHandleScroll); 45 | 46 | // Show down scroll if the scroll height (entire child div) is larger than client height (visible child div) 47 | useEffect(() => { 48 | if (childContainerRef.current == null) return; 49 | const childContainer = childContainerRef.current as HTMLElement; 50 | if (childContainer.scrollHeight > childContainer.clientHeight) setShowDownScroll(true); 51 | }, [childContainerRef.current]); 52 | 53 | return ( 54 | 57 | {containerTitle} 58 | 59 | } 60 | > 61 | {showUpScroll && ( 62 |
63 | 64 |
65 | )} 66 |
67 | {children} 68 |
69 | {showDownScroll && ( 70 |
71 | 72 |
73 | )} 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/components/Conversation/ConversationHeader.tsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import React from 'react'; 4 | 5 | import Button from '@cloudscape-design/components/button'; 6 | import ButtonDropdown from '@cloudscape-design/components/button-dropdown'; 7 | import Header from '@cloudscape-design/components/header'; 8 | import SpaceBetween from '@cloudscape-design/components/space-between'; 9 | 10 | import { MedicalScribeJob } from '@aws-sdk/client-transcribe'; 11 | 12 | import { getPresignedUrl, getS3Object } from '@/utils/S3Api'; 13 | 14 | type ConversationHeaderProps = { 15 | jobDetails: MedicalScribeJob | null; 16 | setShowOutputModal: React.Dispatch>; 17 | }; 18 | 19 | export function ConversationHeader({ jobDetails, setShowOutputModal }: ConversationHeaderProps) { 20 | async function openUrl(detail: { id: string }) { 21 | let jobUrl: string = ''; 22 | if (detail.id === 'audio') { 23 | jobUrl = jobDetails?.Media?.MediaFileUri as string; 24 | } else if (detail.id === 'transcript') { 25 | jobUrl = jobDetails?.MedicalScribeOutput?.TranscriptFileUri as string; 26 | } else if (detail.id === 'summary') { 27 | jobUrl = jobDetails?.MedicalScribeOutput?.ClinicalDocumentUri as string; 28 | } 29 | if (jobUrl) { 30 | const presignedUrl = await getPresignedUrl(getS3Object(jobUrl)); 31 | window.open(presignedUrl, '_blank'); 32 | } 33 | } 34 | 35 | return ( 36 |
40 | openUrl(detail)} 47 | > 48 | Download 49 | 50 | 51 | 52 | } 53 | > 54 | {jobDetails?.MedicalScribeJobName} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Conversation/LeftPanel/ClinicalInsight.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useMemo } from 'react'; 2 | 3 | import SpaceBetween from '@cloudscape-design/components/space-between'; 4 | 5 | import ValueWithLabel from '@/components/Common/ValueWithLabel'; 6 | import { IClinicalInsight } from '@/types/HealthScribeTranscript'; 7 | import toTitleCase from '@/utils/toTitleCase'; 8 | 9 | import { OntologyLinking } from '../Common/OntologyLinking'; 10 | import ClinicalInsightsAttributesTable from './ClinicalInsightAttributesTable'; 11 | 12 | type ClinicalInsightProps = { 13 | wordClinicalEntity: IClinicalInsight; 14 | }; 15 | function ClinicalInsight({ wordClinicalEntity }: ClinicalInsightProps) { 16 | const hasClinicalInsights = useMemo(() => { 17 | return typeof wordClinicalEntity !== 'undefined' && Object.keys(wordClinicalEntity)?.length > 0; 18 | }, [wordClinicalEntity]); 19 | 20 | if (hasClinicalInsights) { 21 | const clinicalInsight = wordClinicalEntity as IClinicalInsight; 22 | return ( 23 | 24 |
25 | Clinical Insight: 26 | {clinicalInsight.Spans[0].Content} 27 |
28 | 29 | 30 | {toTitleCase(clinicalInsight.Category.replaceAll('_', ' '))} 31 | 32 | 33 | {toTitleCase(clinicalInsight.Type.replaceAll('_', ' '))} 34 | 35 | 36 | {clinicalInsight.Attributes.length > 0 && ( 37 | 38 | )} 39 | 44 |
45 | ); 46 | } else { 47 | return; 48 | } 49 | } 50 | 51 | export default memo(ClinicalInsight); 52 | -------------------------------------------------------------------------------- /src/components/Conversation/LeftPanel/ClinicalInsightAttributesTable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Box from '@cloudscape-design/components/box'; 4 | import ExpandableSection from '@cloudscape-design/components/expandable-section'; 5 | import SpaceBetween from '@cloudscape-design/components/space-between'; 6 | import Table from '@cloudscape-design/components/table'; 7 | 8 | import { IAttribute } from '@/types/HealthScribeTranscript'; 9 | import toTitleCase from '@/utils/toTitleCase'; 10 | 11 | type ClinicalInsightsAttributesTableProps = { 12 | attributes: IAttribute[]; 13 | }; 14 | export default function ClinicalInsightsAttributesTable({ attributes }: ClinicalInsightsAttributesTableProps) { 15 | return ( 16 | 17 |
toTitleCase(item.Type.replaceAll('_', ' ')), 23 | sortingField: 'type', 24 | isRowHeader: true, 25 | }, 26 | { id: 'Entity', header: 'Entity', cell: (item) => item.Spans[0].Content, sortingField: 'entity' }, 27 | ]} 28 | items={attributes} 29 | loadingText="Loading attributes" 30 | sortingDisabled 31 | empty={ 32 | 33 | 34 | No attributes 35 | 36 | 37 | } 38 | variant={'embedded'} 39 | /> 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/Conversation/LeftPanel/LeftPanel.module.css: -------------------------------------------------------------------------------- 1 | .hidescrollbar { 2 | overflow: scroll; 3 | -ms-overflow-style: none; /* IE and Edge */ 4 | scrollbar-width: none; /* mozilla */ 5 | } 6 | .hidescrollbar::-webkit-scrollbar { 7 | display: none; /* Safari and Chrome browsers */ 8 | } 9 | 10 | .row { 11 | white-space: nowrap !important; 12 | overflow: scroll !important; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Conversation/LeftPanel/TranscriptSegment.tsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; 4 | 5 | import WaveSurfer from 'wavesurfer.js'; 6 | 7 | import { IClinicalInsight, IProcessedTranscript } from '@/types/HealthScribeTranscript'; 8 | 9 | import { WordPopoverTranscript } from './WordPopover'; 10 | 11 | interface TranscriptSegmentProps { 12 | script: IProcessedTranscript; 13 | smallTalkCheck: boolean; 14 | audioTime: number; 15 | audioReady: boolean; 16 | wavesurfer: React.RefObject; 17 | } 18 | 19 | export const TranscriptSegment = memo(function TranscriptSegment({ 20 | script, 21 | smallTalkCheck, 22 | audioTime, 23 | audioReady, 24 | wavesurfer, 25 | }: TranscriptSegmentProps) { 26 | const segmentRef = useRef(null); 27 | const [triggerKey, setTriggerKey] = useState(false); 28 | 29 | const executeScroll = () => { 30 | (segmentRef.current as HTMLDivElement).scrollIntoView({ 31 | behavior: 'smooth', 32 | // jump to start when audio isn't playing. otherwise, jump to nearest 33 | block: wavesurfer.current?.isPlaying() ? 'nearest' : 'start', 34 | inline: 'center', 35 | }); 36 | setTriggerKey(true); 37 | }; 38 | 39 | useEffect(() => { 40 | if (audioTime >= script.BeginAudioTime && audioTime <= script.EndAudioTime && !triggerKey) { 41 | executeScroll(); 42 | } else if ((audioTime < script.BeginAudioTime || audioTime > script.EndAudioTime) && triggerKey) { 43 | setTriggerKey(false); 44 | } 45 | // eslint-disable-next-line 46 | }, [audioTime]); 47 | 48 | const disableSegment = useMemo(() => { 49 | return ['OTHER', 'SMALL_TALK'].includes(script.SectionDetails.SectionName) && smallTalkCheck; 50 | }, [script.SectionDetails.SectionName, smallTalkCheck]); 51 | 52 | const audioDuration = useMemo(() => { 53 | if (audioReady) { 54 | return wavesurfer.current?.getDuration() || -1; 55 | } else { 56 | return -1; 57 | } 58 | }, [audioReady]); 59 | 60 | return ( 61 |
62 | {script.Words.map((word, i) => { 63 | // punctuation (.,?, etc.) the same BeingAudioTime and EndAudioTime 64 | const isPunctuation = word.BeginAudioTime === word.EndAudioTime; 65 | // highlight the word if the current audio time is between where the word time starts and ends 66 | const highlightWord = 67 | audioTime > word.BeginAudioTime && audioTime < word.EndAudioTime && !isPunctuation; 68 | // highlight the word as a clinical entity if word.ClinicalEntity and word.Type exist 69 | const isClinicalEntity = !!word.ClinicalEntity && !!word.Type; 70 | 71 | if (!word.Alternatives?.[0]) return false; 72 | 73 | return ( 74 | 87 | ); 88 | })} 89 |
90 | ); 91 | }); 92 | -------------------------------------------------------------------------------- /src/components/Conversation/LeftPanel/WordPopover.tsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import React, { memo, useMemo } from 'react'; 4 | 5 | import * as awsui from '@cloudscape-design/design-tokens'; 6 | import Box from '@cloudscape-design/components/box'; 7 | import Button from '@cloudscape-design/components/button'; 8 | import Popover from '@cloudscape-design/components/popover'; 9 | import SpaceBetween from '@cloudscape-design/components/space-between'; 10 | 11 | import toast from 'react-hot-toast'; 12 | import WaveSurfer from 'wavesurfer.js'; 13 | 14 | import ValueWithLabel from '@/components/Common/ValueWithLabel'; 15 | import { IClinicalInsight, ITranscriptWordBatch } from '@/types/HealthScribeTranscript'; 16 | 17 | import ClinicalInsight from './ClinicalInsight'; 18 | 19 | type PopOverCompProps = { 20 | isPunctuation: boolean; 21 | highlightWord: boolean; 22 | disableSegment?: boolean; 23 | isClinicalEntity?: boolean; 24 | wordBeginAudioTime?: number; 25 | audioDuration?: number; 26 | word: ITranscriptWordBatch; 27 | wordClinicalEntity?: IClinicalInsight; 28 | audioReady?: boolean; 29 | wavesurfer?: React.RefObject; 30 | }; 31 | 32 | function WordPopover({ 33 | isPunctuation, 34 | highlightWord, 35 | disableSegment = false, 36 | isClinicalEntity = false, 37 | wordBeginAudioTime, 38 | audioDuration, 39 | word, 40 | wordClinicalEntity, 41 | audioReady, 42 | wavesurfer, 43 | }: PopOverCompProps) { 44 | const wordStyle = useMemo(() => { 45 | const style = { 46 | color: '', 47 | fontWeight: 'normal', 48 | }; 49 | 50 | if (isClinicalEntity) { 51 | style.color = awsui.colorTextStatusInfo; 52 | style.fontWeight = 'bold'; 53 | } 54 | if (highlightWord) style.color = awsui.colorTextStatusError; 55 | if (disableSegment) style.color = awsui.colorTextStatusInactive; 56 | 57 | return style; 58 | }, [highlightWord, disableSegment, isClinicalEntity]); 59 | 60 | const wordConfidence = useMemo(() => { 61 | if (typeof word.Confidence === 'number') { 62 | if (word.Confidence === 0) return 'n/a'; 63 | return Math.round(word.Confidence * 100 * 100) / 100 + '%'; 64 | } else { 65 | return 'n/a'; 66 | } 67 | }, [word.Confidence]); 68 | 69 | /** 70 | * Jump to button is disabled if audioDuration or wordBeginAudioTime is not available 71 | * i.e. if selecting the word from Insights (vs Transcript) 72 | * @constructor 73 | */ 74 | function JumpToButton() { 75 | if (audioDuration && wordBeginAudioTime) { 76 | return ( 77 | 78 | 90 | 91 | ); 92 | } else { 93 | return false; 94 | } 95 | } 96 | 97 | return ( 98 | <> 99 | {!isPunctuation && } 100 | 107 | {wordConfidence} 108 | {wordClinicalEntity && } 109 | 110 | 111 | } 112 | > 113 | { 116 | toast.success('Copied Text!'); 117 | }} 118 | > 119 | {word.Content} 120 | 121 | 122 | 123 | ); 124 | } 125 | 126 | export const WordPopoverTranscript = memo(WordPopover); 127 | -------------------------------------------------------------------------------- /src/components/Conversation/LeftPanel/index.ts: -------------------------------------------------------------------------------- 1 | import LeftPanel from './LeftPanel'; 2 | 3 | export default LeftPanel; 4 | -------------------------------------------------------------------------------- /src/components/Conversation/RightPanel/RightPanelComponents.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import Box from '@cloudscape-design/components/box'; 4 | import Button from '@cloudscape-design/components/button'; 5 | import FormField from '@cloudscape-design/components/form-field'; 6 | import Modal from '@cloudscape-design/components/modal'; 7 | import Slider from '@cloudscape-design/components/slider'; 8 | import SpaceBetween from '@cloudscape-design/components/space-between'; 9 | 10 | import { useAppSettingsContext } from '@/store/appSettings'; 11 | 12 | import { ComprehendMedicalNereCost, EnableComprehendMedicalPopover } from '../Common/ComprehendMedical'; 13 | 14 | type RightPanelActionsProps = { 15 | hasInsightSections: boolean; 16 | dataExtracted: boolean; 17 | extractingData: boolean; 18 | clinicalDocumentNereUnits: 0 | { [key: string]: number }; 19 | setRightPanelSettingsOpen: React.Dispatch>; 20 | handleExtractHealthData: () => void; 21 | }; 22 | export function RightPanelActions({ 23 | hasInsightSections, 24 | dataExtracted, 25 | extractingData, 26 | clinicalDocumentNereUnits, 27 | setRightPanelSettingsOpen, 28 | handleExtractHealthData, 29 | }: RightPanelActionsProps) { 30 | const { appSettings } = useAppSettingsContext(); 31 | const comprehendMedicalEnabled = useMemo(() => appSettings['app.comprehendMedicalEnabled'], [appSettings]); 32 | 33 | const extractHealthDataEnabled = useMemo( 34 | () => hasInsightSections && comprehendMedicalEnabled, 35 | [hasInsightSections, comprehendMedicalEnabled] 36 | ); 37 | 38 | return ( 39 | 40 | 48 | 49 | 50 | 51 | ); 52 | } 53 | 54 | type RightPanelSettingsProps = { 55 | rightPanelSettingsOpen: boolean; 56 | setRightPanelSettingsOpen: React.Dispatch>; 57 | acceptableConfidence: number; 58 | setAcceptableConfidence: (confidence: number) => void; 59 | }; 60 | export function RightPanelSettings({ 61 | rightPanelSettingsOpen, 62 | setRightPanelSettingsOpen, 63 | acceptableConfidence, 64 | setAcceptableConfidence, 65 | }: RightPanelSettingsProps) { 66 | return ( 67 | setRightPanelSettingsOpen(false)} 69 | visible={rightPanelSettingsOpen} 70 | footer={ 71 | 72 | 75 | 76 | 77 | 80 | 83 | 84 | 85 | 86 | } 87 | header="Insights Settings" 88 | > 89 | 93 | setAcceptableConfidence(detail.value)} 95 | value={acceptableConfidence} 96 | max={99} 97 | min={0} 98 | /> 99 | 100 | 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/components/Conversation/RightPanel/SummarizedConcepts.module.css: -------------------------------------------------------------------------------- 1 | .summaryList { 2 | margin-top: 1px; 3 | margin-bottom: 1px; 4 | padding-left: 15px; 5 | list-style-position: outside; 6 | } 7 | 8 | .summaryListItem { 9 | padding-top: 2px; 10 | padding-bottom: 5px; 11 | padding-left: 0; 12 | margin-left: 0; 13 | } 14 | 15 | .summaryListItemIndent { 16 | margin-left: 10px; 17 | } 18 | 19 | .summaryListItemSubHeader { 20 | margin-left: -15px; 21 | } 22 | 23 | .summaryListWithSectionHeader { 24 | padding-left: 0; 25 | } 26 | 27 | .summarizedSegment { 28 | display: inline; 29 | cursor: pointer; 30 | line-height: normal; 31 | font-weight: 400; 32 | } 33 | 34 | .extractedHealthDataWord { 35 | display: inline; 36 | } 37 | -------------------------------------------------------------------------------- /src/components/Conversation/RightPanel/index.ts: -------------------------------------------------------------------------------- 1 | import RightPanel from './RightPanel'; 2 | 3 | export default RightPanel; 4 | -------------------------------------------------------------------------------- /src/components/Conversation/RightPanel/rightPanelUtils.ts: -------------------------------------------------------------------------------- 1 | import { IHealthScribeSummary } from '@/types/HealthScribeSummary'; 2 | 3 | import { processSummarizedSegment } from './summarizedConceptsUtils'; 4 | 5 | export function calculateNereUnits(clinicalDocument: IHealthScribeSummary | undefined) { 6 | if (!clinicalDocument) return 0; 7 | const eachSegment = clinicalDocument.ClinicalDocumentation.Sections.reduce((acc, { Summary }) => { 8 | return ( 9 | acc + 10 | Summary.reduce((acc, { SummarizedSegment }) => { 11 | return acc + Math.ceil(processSummarizedSegment(SummarizedSegment).length / 100); 12 | }, 0) 13 | ); 14 | }, 0); 15 | 16 | const eachSection = clinicalDocument.ClinicalDocumentation.Sections.reduce((acc, { Summary }) => { 17 | return ( 18 | acc + 19 | Math.ceil( 20 | Summary.reduce((acc, { SummarizedSegment }) => { 21 | return acc + processSummarizedSegment(SummarizedSegment).length; 22 | }, 0) / 100 23 | ) 24 | ); 25 | }, 0); 26 | 27 | const allAtOnce = Math.ceil( 28 | clinicalDocument.ClinicalDocumentation.Sections.reduce((acc, { Summary }) => { 29 | return ( 30 | acc + 31 | Summary.reduce((acc, { SummarizedSegment }) => { 32 | return acc + processSummarizedSegment(SummarizedSegment).length; 33 | }, 0) 34 | ); 35 | }, 0) / 100 36 | ); 37 | 38 | return { 39 | eachSegment: eachSegment, 40 | eachSection: eachSection, 41 | allAtOnce: allAtOnce, 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Conversation/RightPanel/sectionOrder.ts: -------------------------------------------------------------------------------- 1 | export const SOAP_SECTION_ORDER = [ 2 | 'CHIEF_COMPLIANT', 3 | 'HISTORY_OF_PRESENT_ILLNESS', 4 | 'PAST_MEDICAL_HISTORY', 5 | 'PAST_FAMILY_HISTORY', 6 | 'PAST_SOCIAL_HISTORY', 7 | 'REVIEW_OF_SYSTEMS', 8 | 'PHYSICAL_EXAMINATION', 9 | 'DIAGNOSTIC_TESTING', 10 | 'ASSESSMENT', 11 | 'PLAN', 12 | ]; 13 | 14 | export const GIRPP_SECTION_ORDER = ['GOAL', 'INTERVENTION', 'RESPONSE', 'PROGRESS', 'PLAN']; 15 | -------------------------------------------------------------------------------- /src/components/Conversation/RightPanel/summarizedConceptsUtils.ts: -------------------------------------------------------------------------------- 1 | import { ExtractedHealthData, SegmentExtractedData, SummarySectionEntityMapping } from '@/types/ComprehendMedical'; 2 | import { ISection } from '@/types/HealthScribeSummary'; 3 | import { flattenAndUnique } from '@/utils/array'; 4 | 5 | /** 6 | * Remove leading dashes and trims the string 7 | * E.g. " - summary" returns "summary" 8 | */ 9 | export function processSummarizedSegment(summarizedSegment: string): string { 10 | return summarizedSegment.trim().replace(/^-/, '').trim(); 11 | } 12 | 13 | /** 14 | * Merge HealthScribe output with Comprehend Medical output 15 | * @param sections - HealthScribe output sections 16 | * @param sectionsWithEntities - Comprehend Medical output sections 17 | * @returns SummarySectionEntityMapping[] 18 | */ 19 | export function mergeHealthScribeOutputWithComprehendMedicalOutput( 20 | sections: ISection[], 21 | sectionsWithEntities: ExtractedHealthData[] 22 | ): SummarySectionEntityMapping[] { 23 | if (sections.length === 0 || sectionsWithEntities.length === 0) return []; 24 | 25 | const buildSectionsWithExtractedData: SummarySectionEntityMapping[] = []; 26 | 27 | sections.forEach((section) => { 28 | const sectionName = section.SectionName; 29 | const sectionWithEntities = sectionsWithEntities.find((s) => s.SectionName === sectionName); 30 | 31 | const currentSectionExtractedData: SegmentExtractedData[] = []; 32 | section.Summary.forEach((summary, i) => { 33 | const segmentExtractedData: SegmentExtractedData = { words: [] }; 34 | const summarizedSegment = processSummarizedSegment(summary.SummarizedSegment); 35 | const summarizedSegmentSplit = summarizedSegment.split(' '); 36 | if (typeof sectionWithEntities === 'undefined') return; 37 | const segmentEvidence = sectionWithEntities?.ExtractedEntities?.[i]?.Entities || []; 38 | segmentExtractedData.words = summarizedSegmentSplit.map((w) => { 39 | return { word: w, linkedId: [] }; 40 | }); 41 | segmentExtractedData.extractedData = segmentEvidence; 42 | 43 | // offset character map. key: character index, value: array of extractedData ids 44 | const offsetIdMap = new Map(); 45 | segmentExtractedData.extractedData.forEach(({ BeginOffset, EndOffset, Id }) => { 46 | if (typeof BeginOffset === 'number' && typeof EndOffset === 'number') { 47 | for (let i = BeginOffset; i <= EndOffset; i++) { 48 | if (!offsetIdMap.has(i)) { 49 | offsetIdMap.set(i, []); 50 | } 51 | offsetIdMap.get(i).push(Id); 52 | } 53 | } 54 | }); 55 | 56 | // iterate over each word by character. if the character appears in the offset map, 57 | // find the unique extracted data ids and append it to the word object 58 | let charCount = 0; 59 | let charCurrent = 0; 60 | for (let wordIndex = 0; wordIndex < summarizedSegmentSplit.length; wordIndex++) { 61 | const word = summarizedSegmentSplit[wordIndex]; 62 | const wordLength = word.length; 63 | charCount += wordLength + 1; 64 | const wordDataIds = []; 65 | // iterate from the current character to the current character + word length + 1 (space) 66 | while (charCurrent < charCount) { 67 | wordDataIds.push(offsetIdMap.get(charCurrent) || []); 68 | charCurrent++; 69 | } 70 | segmentExtractedData.words[wordIndex].linkedId = flattenAndUnique(wordDataIds); 71 | 72 | // break out of the loop if there's no more extracted health data 73 | if (charCount >= Math.max(...offsetIdMap.keys())) break; 74 | } 75 | 76 | currentSectionExtractedData.push(segmentExtractedData); 77 | }); 78 | buildSectionsWithExtractedData.push({ 79 | SectionName: sectionName, 80 | Summary: currentSectionExtractedData, 81 | }); 82 | }); 83 | return buildSectionsWithExtractedData; 84 | } 85 | -------------------------------------------------------------------------------- /src/components/Conversation/TopPanel/TopPanel.module.css: -------------------------------------------------------------------------------- 1 | .segmentControls { 2 | display: flex; 3 | align-items: center; 4 | min-height: 25px; 5 | } 6 | 7 | .minWidthDropdown > div { 8 | min-width: 110px; 9 | } 10 | 11 | .ctrlHeading { 12 | padding: 0 !important; 13 | margin-bottom: 15px !important; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Conversation/TopPanel/extractRegions.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { Peaks } from 'wavesurfer.js/types/backend'; 4 | 5 | export const extractRegions = (peaks: Peaks, duration: number) => { 6 | // Silence params 7 | const minValue = 0.0015; 8 | const minSeconds = 0.25; 9 | 10 | const length = peaks.length; 11 | const coef = duration / length; 12 | const minLen = minSeconds / coef; 13 | 14 | // Gather silence indeces 15 | const silences: number[] = []; 16 | peaks.forEach((val, index) => { 17 | if (Math.abs(val as number) < minValue) { 18 | silences.push(index); 19 | } 20 | }); 21 | 22 | // Cluster silence values 23 | const clusters: number[][] = []; 24 | silences.forEach(function (val, index) { 25 | // Add ID to silence cluster if the current value is less than the previous value + minimum length 26 | if (clusters.length && val <= silences[index - 1] + minLen) { 27 | clusters[clusters.length - 1].push(val); 28 | } else { 29 | clusters.push([val]); 30 | } 31 | }); 32 | 33 | // Filter silence clusters by minimum length 34 | const fClusters = clusters.filter((cluster) => cluster.length >= minLen); 35 | 36 | const regions = fClusters.map((cluster) => { 37 | return { 38 | start: cluster[0], 39 | end: cluster[cluster.length - 1], 40 | }; 41 | }); 42 | 43 | // Return time-based regions 44 | return regions.map((reg: { start: number; end: number }) => { 45 | return { 46 | start: Math.round(reg.start * coef * 10) / 10, 47 | end: Math.round(reg.end * coef * 10) / 10, 48 | }; 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/Conversation/TopPanel/index.ts: -------------------------------------------------------------------------------- 1 | import TopPanel from './TopPanel'; 2 | 3 | export default TopPanel; 4 | -------------------------------------------------------------------------------- /src/components/Conversation/ViewOutput/ViewOutput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AceEditor from 'react-ace'; 3 | 4 | import Container from '@cloudscape-design/components/container'; 5 | import Grid from '@cloudscape-design/components/grid'; 6 | import Header from '@cloudscape-design/components/header'; 7 | import Modal from '@cloudscape-design/components/modal'; 8 | 9 | import 'ace-builds/src-noconflict/mode-json'; 10 | import 'ace-builds/src-noconflict/theme-github'; 11 | import 'ace-builds/src-noconflict/theme-twilight'; 12 | 13 | import { useAppThemeContext } from '@/store/appTheme'; 14 | 15 | type ReadOnlyAceEditorProps = { 16 | appColor: string; 17 | value: string; 18 | }; 19 | 20 | function ReadOnlyAceEditor({ appColor, value }: ReadOnlyAceEditorProps) { 21 | return ( 22 | 34 | ); 35 | } 36 | 37 | type ViewResultsProps = { 38 | setVisible: (visible: boolean) => void; 39 | transcriptString: string; 40 | clinicalDocumentString: string; 41 | }; 42 | export default function ViewOutput({ setVisible, transcriptString, clinicalDocumentString }: ViewResultsProps) { 43 | const { appTheme } = useAppThemeContext(); 44 | 45 | return ( 46 | setVisible(false)} visible={true} header="HealthScribe Output"> 47 | 48 | Transcript}> 49 | 50 | 51 | Clinical Documentation}> 52 | 53 | 54 | 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Conversation/ViewOutput/index.ts: -------------------------------------------------------------------------------- 1 | import ViewOutput from './ViewOutput'; 2 | 3 | export default ViewOutput; 4 | -------------------------------------------------------------------------------- /src/components/Conversation/index.ts: -------------------------------------------------------------------------------- 1 | import Conversation from './Conversation'; 2 | 3 | export default Conversation; 4 | -------------------------------------------------------------------------------- /src/components/Conversation/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | export type HighlightId = { 5 | allSegmentIds: string[]; 6 | selectedSegmentId: string; 7 | }; 8 | 9 | export type SmallTalkList = { 10 | BeginAudioTime: number; 11 | EndAudioTime: number; 12 | }[]; 13 | -------------------------------------------------------------------------------- /src/components/Conversations/ConversationsFilter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import Button from '@cloudscape-design/components/button'; 4 | import Form from '@cloudscape-design/components/form'; 5 | import Grid from '@cloudscape-design/components/grid'; 6 | import Input from '@cloudscape-design/components/input'; 7 | import Select from '@cloudscape-design/components/select'; 8 | 9 | import { useDebounce } from 'use-debounce'; 10 | 11 | import { ListHealthScribeJobsProps } from '@/utils/HealthScribeApi'; 12 | 13 | const STATUS_SELECTION = [ 14 | { label: 'All', value: 'ALL' }, 15 | { label: 'Completed', value: 'COMPLETED' }, 16 | { label: 'In Progress', value: 'IN_PROGRESS' }, 17 | { label: 'Queued', value: 'QUEUED' }, 18 | { label: 'Failed', value: 'FAILED' }, 19 | ]; 20 | 21 | type ConversationsFilterProps = { 22 | listHealthScribeJobs: (searchFilter: ListHealthScribeJobsProps) => Promise; 23 | setSearchParams: React.Dispatch>; 24 | searchParams: ListHealthScribeJobsProps; 25 | }; 26 | 27 | export function ConversationsFilter({ listHealthScribeJobs, setSearchParams, searchParams }: ConversationsFilterProps) { 28 | const [debouncedSearchParams] = useDebounce(searchParams, 500); 29 | 30 | // Update list initially & deboucned search params 31 | useEffect(() => { 32 | listHealthScribeJobs(debouncedSearchParams).catch(console.error); 33 | }, [debouncedSearchParams]); 34 | 35 | // Update searchParam to id: value 36 | function handleInputChange(id: string, value: string) { 37 | setSearchParams((currentSearchParams) => { 38 | return { 39 | ...currentSearchParams, 40 | [id]: value, 41 | }; 42 | }); 43 | } 44 | 45 | return ( 46 |
47 | 48 | handleInputChange('JobNameContains', detail.value)} 52 | /> 53 | updateAudioLineSpeaker(audioLine.id, detail.selectedOption)} 76 | options={SPEAKERS} 77 | /> 78 | 79 | 80 |