diff --git a/packages/tupaia-web-server/package.json b/packages/tupaia-web-server/package.json index 4182f76e5b..1af62e0f0d 100644 --- a/packages/tupaia-web-server/package.json +++ b/packages/tupaia-web-server/package.json @@ -38,6 +38,7 @@ "express": "^4.19.2", "lodash.groupby": "^4.6.0", "lodash.keyby": "^4.6.0", + "openai": "^4.71.0", "winston": "^3.3.3" }, "devDependencies": { diff --git a/packages/tupaia-web-server/src/app/createApp.ts b/packages/tupaia-web-server/src/app/createApp.ts index 5599e14e2c..ce7b632824 100644 --- a/packages/tupaia-web-server/src/app/createApp.ts +++ b/packages/tupaia-web-server/src/app/createApp.ts @@ -28,6 +28,7 @@ export async function createApp(db: TupaiaDatabase = new TupaiaDatabase()) { .useSessionModel(TupaiaWebSessionModel) .useAttachSession(attachSessionIfAvailable) .attachApiClientToContext(authHandlerProvider) + .post('openai', handleWith(routes.OpenAIRoute)) .get('report/:reportCode', handleWith(routes.ReportRoute)) .get( 'legacyDashboardReport/:reportCode', diff --git a/packages/tupaia-web-server/src/routes/OpenAIRoute.ts b/packages/tupaia-web-server/src/routes/OpenAIRoute.ts new file mode 100644 index 0000000000..e075b4720f --- /dev/null +++ b/packages/tupaia-web-server/src/routes/OpenAIRoute.ts @@ -0,0 +1,47 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import OpenAI from 'openai'; +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; + +export interface Params { + entityCode: string; + projectCode: string; +} +export type ResBody = Record; +export type ReqBody = Record; +export interface ReqQuery {} + +export type OpenAIRequest = Request; + +const apiKey = process.env.OPENAI_API_KEY; + +export class OpenAIRoute extends Route { + public async buildResponse() { + const { userPrompt, systemPrompt } = this.req.body; + + const openAIClient = new OpenAI({ + apiKey, + }); + + const completion = await openAIClient.chat.completions.create({ + messages: [ + { + role: 'system', + content: [ + { + type: 'text', + text: systemPrompt, + }, + ], + }, + { role: 'user', content: JSON.stringify(userPrompt) }, + ], + model: 'gpt-3.5-turbo', + }); + + return { message: completion.choices[0].message as unknown as string }; + } +} diff --git a/packages/tupaia-web-server/src/routes/index.ts b/packages/tupaia-web-server/src/routes/index.ts index e8939e01a6..fc7ee3d1a5 100644 --- a/packages/tupaia-web-server/src/routes/index.ts +++ b/packages/tupaia-web-server/src/routes/index.ts @@ -39,3 +39,4 @@ export { export { ExportMapOverlayRequest, ExportMapOverlayRoute } from './ExportMapOverlayRoute'; export { LoginRoute, LoginRequest } from './LoginRoute'; export { CountriesRequest, CountriesRoute } from './CountriesRoute'; +export { OpenAIRoute, OpenAIRequest } from './OpenAIRoute'; diff --git a/packages/tupaia-web/package.json b/packages/tupaia-web/package.json index d3f03074f7..73d0f59f07 100644 --- a/packages/tupaia-web/package.json +++ b/packages/tupaia-web/package.json @@ -37,6 +37,7 @@ "downloadjs": "1.4.7", "leaflet": "^1.7.1", "moment": "^2.24.0", + "openai": "^4.70.3", "react": "^16.13.1", "react-dom": "^16.13.1", "react-hook-form": "^6.15.1", diff --git a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx index 693fb0f44c..cefa00052e 100644 --- a/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx +++ b/packages/tupaia-web/src/features/Dashboard/ExportDashboard/ExportConfig.tsx @@ -3,9 +3,10 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import React from 'react'; +import React, { useState, ChangeEvent } from 'react'; import styled from 'styled-components'; import { useParams } from 'react-router'; +import { TextField } from '@material-ui/core'; import { Button, LoadingContainer } from '@tupaia/ui-components'; import { useEntity, useProject } from '../../../api/queries'; import { useExportDashboard } from '../../../api/mutations'; @@ -28,11 +29,23 @@ const ButtonGroup = styled.div` justify-content: flex-end; `; +const SystemPrompt = styled.div` + margin-top: 1rem; + + button { + display: block; + } + + .MuiTextField-root { + width: 100%; + } +`; + const Wrapper = styled.div` display: flex; flex-direction: column; flex-grow: 1; - width 100%; + width: 100%; align-items: start; section + section { margin-top: 1.5rem; @@ -119,8 +132,14 @@ interface ExportDashboardProps { onClose: () => void; selectedDashboardItems: string[]; } +const defaultSystemPrompt = + 'You are writing for a health software app that provides health data to a government department.' + + 'The summary will be used for an exported PDF. Summarise the dashboard in a sentence using up to 250' + + 'characters and include the title of the dashboard, the project, country and a summary the dashboard items.'; export const ExportConfig = ({ onClose, selectedDashboardItems }: ExportDashboardProps) => { + const [showSystemPrompt, setShowSystemPrompt] = useState(false); + const [systemPrompt, setSystemPrompt] = useState(defaultSystemPrompt); const { projectCode, entityCode, dashboardName } = useParams(); const { data: project } = useProject(projectCode); const { data: entity } = useEntity(projectCode, entityCode); @@ -152,6 +171,7 @@ export const ExportConfig = ({ onClose, selectedDashboardItems }: ExportDashboar return item?.config?.type === DashboardItemVizTypes.Chart; }); + const toggleSystemPrompt = () => setShowSystemPrompt(!showSystemPrompt); return (
- + @@ -189,6 +212,23 @@ export const ExportConfig = ({ onClose, selectedDashboardItems }: ExportDashboar }} /> + + {showSystemPrompt && ( + ) => { + setSystemPrompt(e.target.value); + }} + /> + )} + + {!isLoading && ( { - const { exportDescription, updateExportDescription } = useExportSettings(); +export const ExportDescriptionInput = ({ systemPrompt, selectedDashboardItems }) => { + const { exportDescription, setExportDescription } = useExportSettings(); + const { projectCode, entityCode } = useParams(); + const { activeDashboard } = useDashboard(); + const { data: project } = useProject(projectCode); + const { data: entity } = useEntity(projectCode, entityCode); + + const { mutate } = useMutation( + ['openai'], + systemPrompt => { + const userPrompt = { + title: activeDashboard?.name, + project: project?.name, + country: entity?.name, + dashboardItems: activeDashboard?.items.filter(item => + selectedDashboardItems.includes(item.code), + ), + }; + return post('openai', { data: { userPrompt, systemPrompt } }); + }, + { + onSuccess: data => { + if (data.message.content) { + setExportDescription(data.message.content); + } + }, + }, + ); + const showMaxCharsWarning = exportDescription.length >= MAX_CHARACTERS; + const handleChange = (e: ChangeEvent) => { + setExportDescription(e.target.value); + }; + + const handleClick = async () => { + mutate(systemPrompt); + }; return ( Description @@ -63,7 +103,7 @@ export const ExportDescriptionInput = () => { multiline rows={6} value={exportDescription} - onChange={updateExportDescription} + onChange={handleChange} variant="outlined" error={showMaxCharsWarning} inputProps={{ maxLength: MAX_CHARACTERS }} @@ -73,6 +113,7 @@ export const ExportDescriptionInput = () => { {showMaxCharsWarning && 'Character limit reached: '} {exportDescription.length}/{MAX_CHARACTERS} + ); }; diff --git a/packages/tupaia-web/src/features/ExportSettings/ExportSettingsContext.tsx b/packages/tupaia-web/src/features/ExportSettings/ExportSettingsContext.tsx index 8b5f3de303..9313b50188 100644 --- a/packages/tupaia-web/src/features/ExportSettings/ExportSettingsContext.tsx +++ b/packages/tupaia-web/src/features/ExportSettings/ExportSettingsContext.tsx @@ -70,10 +70,6 @@ export const useExportSettings = () => { setExportWithTable(e.target.checked); }; - const updateExportDescription = (e: ChangeEvent) => { - setExportDescription(e.target.value); - }; - const updateSeparatePagePerItem = (e: ChangeEvent) => { setSeparatePagePerItem(e.target.value === 'true'); }; @@ -95,7 +91,7 @@ export const useExportSettings = () => { updateExportFormat, updateExportWithLabels, updateExportWithTable, - updateExportDescription, + setExportDescription, resetExportSettings, separatePagePerItem, updateSeparatePagePerItem, diff --git a/yarn.lock b/yarn.lock index b49907f617..dadcb67398 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10629,6 +10629,7 @@ __metadata: express: ^4.19.2 lodash.groupby: ^4.6.0 lodash.keyby: ^4.6.0 + openai: ^4.71.0 winston: ^3.3.3 languageName: unknown linkType: soft @@ -10663,6 +10664,7 @@ __metadata: moment: ^2.24.0 msw: ^2.0.9 npm-run-all: ^4.1.5 + openai: ^4.70.3 react: ^16.13.1 react-dom: ^16.13.1 react-hook-form: ^6.15.1 @@ -11641,7 +11643,7 @@ __metadata: languageName: node linkType: hard -"@types/node-fetch@npm:^2.6.11": +"@types/node-fetch@npm:^2.6.11, @types/node-fetch@npm:^2.6.4": version: 2.6.11 resolution: "@types/node-fetch@npm:2.6.11" dependencies: @@ -11690,6 +11692,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^18.11.18": + version: 18.19.64 + resolution: "@types/node@npm:18.19.64" + dependencies: + undici-types: ~5.26.4 + checksum: e7680215b03c9bee8a33947f03d06048e8e460f23b1b7b29c45350cf437faa5f8fcb7d8c3eb8dfec8427923e7a184df42bc710c1b6252b4852e3ed7064c6228f + languageName: node + linkType: hard + "@types/nodemailer@npm:^6.4.15": version: 6.4.16 resolution: "@types/nodemailer@npm:6.4.16" @@ -20189,6 +20200,13 @@ __metadata: languageName: node linkType: hard +"form-data-encoder@npm:1.7.2": + version: 1.7.2 + resolution: "form-data-encoder@npm:1.7.2" + checksum: aeebd87a1cb009e13cbb5e4e4008e6202ed5f6551eb6d9582ba8a062005178907b90f4887899d3c993de879159b6c0c940af8196725b428b4248cec5af3acf5f + languageName: node + linkType: hard + "form-data@npm:^2.3.1, form-data@npm:^2.3.3": version: 2.5.1 resolution: "form-data@npm:2.5.1" @@ -20229,6 +20247,16 @@ __metadata: languageName: node linkType: hard +"formdata-node@npm:^4.3.2": + version: 4.4.1 + resolution: "formdata-node@npm:4.4.1" + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + checksum: d91d4f667cfed74827fc281594102c0dabddd03c9f8b426fc97123eedbf73f5060ee43205d89284d6854e2fc5827e030cd352ef68b93beda8decc2d72128c576 + languageName: node + linkType: hard + "formidable@npm:^1.2.0": version: 1.2.1 resolution: "formidable@npm:1.2.1" @@ -27165,6 +27193,13 @@ __metadata: languageName: node linkType: hard +"node-domexception@npm:1.0.0": + version: 1.0.0 + resolution: "node-domexception@npm:1.0.0" + checksum: ee1d37dd2a4eb26a8a92cd6b64dfc29caec72bff5e1ed9aba80c294f57a31ba4895a60fd48347cf17dd6e766da0ae87d75657dfd1f384ebfa60462c2283f5c7f + languageName: node + linkType: hard + "node-environment-flags@npm:^1.0.5": version: 1.0.6 resolution: "node-environment-flags@npm:1.0.6" @@ -28040,6 +28075,50 @@ __metadata: languageName: node linkType: hard +"openai@npm:^4.70.3": + version: 4.70.3 + resolution: "openai@npm:4.70.3" + dependencies: + "@types/node": ^18.11.18 + "@types/node-fetch": ^2.6.4 + abort-controller: ^3.0.0 + agentkeepalive: ^4.2.1 + form-data-encoder: 1.7.2 + formdata-node: ^4.3.2 + node-fetch: ^2.6.7 + peerDependencies: + zod: ^3.23.8 + peerDependenciesMeta: + zod: + optional: true + bin: + openai: bin/cli + checksum: 6aa40cb8362b966f56dd10c31e2d60ba97989f4d1d3fe18602838b1be49b38bc1ae412b8ebd97a518e450ac2bca71e312508059da1656c0eef015cdfcccfed1f + languageName: node + linkType: hard + +"openai@npm:^4.71.0": + version: 4.71.0 + resolution: "openai@npm:4.71.0" + dependencies: + "@types/node": ^18.11.18 + "@types/node-fetch": ^2.6.4 + abort-controller: ^3.0.0 + agentkeepalive: ^4.2.1 + form-data-encoder: 1.7.2 + formdata-node: ^4.3.2 + node-fetch: ^2.6.7 + peerDependencies: + zod: ^3.23.8 + peerDependenciesMeta: + zod: + optional: true + bin: + openai: bin/cli + checksum: ba4b3772e806c59b1ea1235a40486392c797906e45dd97914f2cd819b4be2996e207c7b7c67d43236692300354f4e9ffa8ebfca6e97d3555655ebf0f3f01e3f2 + languageName: node + linkType: hard + "optimist@npm:~0.6.1": version: 0.6.1 resolution: "optimist@npm:0.6.1" @@ -36123,6 +36202,13 @@ __metadata: languageName: node linkType: hard +"web-streams-polyfill@npm:4.0.0-beta.3": + version: 4.0.0-beta.3 + resolution: "web-streams-polyfill@npm:4.0.0-beta.3" + checksum: dfec1fbf52b9140e4183a941e380487b6c3d5d3838dd1259be81506c1c9f2abfcf5aeb670aeeecfd9dff4271a6d8fef931b193c7bedfb42542a3b05ff36c0d16 + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1"