From 668643c4d722f0bc2217f6a98a015c9b111b508d Mon Sep 17 00:00:00 2001 From: Juan Valacco <97040903+jvalacco-dataherald@users.noreply.github.com> Date: Fri, 3 Nov 2023 16:23:19 -0300 Subject: [PATCH] DH-4804 Query Editor Page Redesign (#274) * DH-4875 [Enterprise] Save Send Run Refactor (#256) * DH-4875 separation of save send and run * DH-4875 refactor save send run * DH-4875 [Enterprise] fix migration script location and adds enumarations * query editor header * remove default padding from page layout - wip query editor sections * finished most of the layout * add secondary message buttons * finished query status - partial message section - partial resubmit * added workflow limitations * finished all flows -- cleanup pending * cleanup * fix bug (#277) * set confidence score to none (#278) * DH-4945 [ai][admin-console] display plain status when no evaluation score is present * save generated message * update submodule * fix test --------- Co-authored-by: Dishen <44216194+DishenWang2023@users.noreply.github.com> Co-authored-by: dishenwang2023 <dishenwang1021@gmail.com> --- apps/ai/clients/admin-console/package.json | 1 + apps/ai/clients/admin-console/pnpm-lock.yaml | 33 ++ .../public/images/slack-black.png | Bin 0 -> 12831 bytes .../{slack-white.png => slack-color.png} | Bin .../src/components/layout/page-layout.tsx | 9 +- ...e-dialog.tsx => custom-message-dialog.tsx} | 45 +- ...oading-sql-results.tsx => loading-box.tsx} | 8 +- .../src/components/query/loading.tsx | 23 +- .../src/components/query/message-section.tsx | 205 ++++++++ .../src/components/query/query-metadata.tsx | 66 +++ .../src/components/query/question.tsx | 16 +- .../src/components/query/section-header.tsx | 13 + .../components/query/send-message-dialog.tsx | 106 ++++ .../src/components/query/sql-editor.tsx | 10 +- .../src/components/query/verify-select.tsx | 80 --- .../src/components/query/workspace.tsx | 497 ++++++++---------- .../src/components/ui/radio-group.tsx | 42 ++ .../src/hooks/api/{ => query}/useQueries.ts | 0 .../src/hooks/api/{ => query}/useQuery.ts | 0 .../api/{ => query}/useQueryExecution.ts | 2 +- .../api/query/useQueryGenerateMessage.ts | 26 + .../hooks/api/{ => query}/useQueryPatch.ts | 18 +- .../hooks/api/query/useQuerySendMessage.ts | 18 + .../admin-console/src/lib/domain/query.ts | 26 +- .../clients/admin-console/src/models/api.ts | 8 +- .../admin-console/src/models/domain.ts | 5 +- .../src/pages/databases/index.tsx | 2 +- .../src/pages/golden-sql/index.tsx | 2 +- .../src/pages/my-account/index.tsx | 2 +- .../src/pages/organization-settings/index.tsx | 2 +- .../src/pages/queries/[queryId]/index.tsx | 53 +- .../admin-console/src/pages/queries/index.tsx | 4 +- apps/ai/server/app.py | 2 +- apps/ai/server/config.py | 4 +- .../1_DH-4875_query_edit_refactor.py | 21 + ...y => 2_DH-4904_replace_llm_credentials.py} | 0 apps/ai/server/dataherald | 2 +- .../server/modules/db_connection/service.py | 8 +- .../server/modules/golden_sql/controller.py | 3 +- .../server/modules/golden_sql/repository.py | 10 +- apps/ai/server/modules/golden_sql/service.py | 23 +- apps/ai/server/modules/instruction/service.py | 10 +- apps/ai/server/modules/query/controller.py | 36 +- .../server/modules/query/models/entities.py | 24 +- .../server/modules/query/models/requests.py | 5 +- .../server/modules/query/models/responses.py | 24 +- apps/ai/server/modules/query/repository.py | 20 +- apps/ai/server/modules/query/service.py | 277 +++++----- .../modules/table_description/service.py | 20 +- .../tests/golden_sql/test_golden_sql_api.py | 1 + apps/ai/server/tests/query/test_query_api.py | 54 +- apps/ai/server/utils/slack.py | 31 +- 52 files changed, 1181 insertions(+), 716 deletions(-) create mode 100644 apps/ai/clients/admin-console/public/images/slack-black.png rename apps/ai/clients/admin-console/public/images/{slack-white.png => slack-color.png} (100%) rename apps/ai/clients/admin-console/src/components/query/{custom-response-dialog.tsx => custom-message-dialog.tsx} (62%) rename apps/ai/clients/admin-console/src/components/query/{loading-sql-results.tsx => loading-box.tsx} (62%) create mode 100644 apps/ai/clients/admin-console/src/components/query/message-section.tsx create mode 100644 apps/ai/clients/admin-console/src/components/query/query-metadata.tsx create mode 100644 apps/ai/clients/admin-console/src/components/query/section-header.tsx create mode 100644 apps/ai/clients/admin-console/src/components/query/send-message-dialog.tsx delete mode 100644 apps/ai/clients/admin-console/src/components/query/verify-select.tsx create mode 100644 apps/ai/clients/admin-console/src/components/ui/radio-group.tsx rename apps/ai/clients/admin-console/src/hooks/api/{ => query}/useQueries.ts (100%) rename apps/ai/clients/admin-console/src/hooks/api/{ => query}/useQuery.ts (100%) rename apps/ai/clients/admin-console/src/hooks/api/{ => query}/useQueryExecution.ts (88%) create mode 100644 apps/ai/clients/admin-console/src/hooks/api/query/useQueryGenerateMessage.ts rename apps/ai/clients/admin-console/src/hooks/api/{ => query}/useQueryPatch.ts (64%) create mode 100644 apps/ai/clients/admin-console/src/hooks/api/query/useQuerySendMessage.ts create mode 100644 apps/ai/server/database/migrations/sprint_62/1_DH-4875_query_edit_refactor.py rename apps/ai/server/database/migrations/sprint_62/{DH-4904_replace_llm_credentials.py => 2_DH-4904_replace_llm_credentials.py} (100%) diff --git a/apps/ai/clients/admin-console/package.json b/apps/ai/clients/admin-console/package.json index 154a6cf9..1d486f92 100644 --- a/apps/ai/clients/admin-console/package.json +++ b/apps/ai/clients/admin-console/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.6", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-select": "^1.2.2", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", diff --git a/apps/ai/clients/admin-console/pnpm-lock.yaml b/apps/ai/clients/admin-console/pnpm-lock.yaml index a65cbc41..28de9024 100644 --- a/apps/ai/clients/admin-console/pnpm-lock.yaml +++ b/apps/ai/clients/admin-console/pnpm-lock.yaml @@ -29,6 +29,9 @@ dependencies: '@radix-ui/react-popover': specifier: ^1.0.6 version: 1.0.6(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-radio-group': + specifier: ^1.1.3 + version: 1.1.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-select': specifier: ^1.2.2 version: 1.2.2(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) @@ -1418,6 +1421,36 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-radio-group@1.1.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.6 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@types/react': 18.2.15 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} peerDependencies: diff --git a/apps/ai/clients/admin-console/public/images/slack-black.png b/apps/ai/clients/admin-console/public/images/slack-black.png new file mode 100644 index 0000000000000000000000000000000000000000..c7b846eb1081d6d28b2cca8f5fb81c38049ca374 GIT binary patch literal 12831 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 bN!(&>*$#*njs*Xi1HzeD8&{rkzy5y##(`o{ literal 0 HcmV?d00001 diff --git a/apps/ai/clients/admin-console/public/images/slack-white.png b/apps/ai/clients/admin-console/public/images/slack-color.png similarity index 100% rename from apps/ai/clients/admin-console/public/images/slack-white.png rename to apps/ai/clients/admin-console/public/images/slack-color.png diff --git a/apps/ai/clients/admin-console/src/components/layout/page-layout.tsx b/apps/ai/clients/admin-console/src/components/layout/page-layout.tsx index bd413fe2..71515bee 100644 --- a/apps/ai/clients/admin-console/src/components/layout/page-layout.tsx +++ b/apps/ai/clients/admin-console/src/components/layout/page-layout.tsx @@ -4,19 +4,22 @@ import { Separator } from '@/components/ui/separator' import { cn } from '@/lib/utils' import { FC, HTMLAttributes } from 'react' -export type PageLayoutProps = HTMLAttributes<HTMLDivElement> +interface PageLayoutProps extends HTMLAttributes<HTMLDivElement> { + disableBreadcrumb?: boolean +} const PageLayout: FC<PageLayoutProps> = ({ className, children, + disableBreadcrumb = false, ...props }: PageLayoutProps) => ( <div className={cn('flex h-screen', className)} {...props}> <SidebarNav /> <div className="w-full h-full overflow-auto flex flex-col"> - <BreadcrumbHeader /> + {!disableBreadcrumb && <BreadcrumbHeader />} <Separator /> - <main className="grow flex flex-col overflow-auto p-6">{children}</main> + <main className="grow flex flex-col overflow-auto">{children}</main> </div> </div> ) diff --git a/apps/ai/clients/admin-console/src/components/query/custom-response-dialog.tsx b/apps/ai/clients/admin-console/src/components/query/custom-message-dialog.tsx similarity index 62% rename from apps/ai/clients/admin-console/src/components/query/custom-response-dialog.tsx rename to apps/ai/clients/admin-console/src/components/query/custom-message-dialog.tsx index 3a064164..bbdfa615 100644 --- a/apps/ai/clients/admin-console/src/components/query/custom-response-dialog.tsx +++ b/apps/ai/clients/admin-console/src/components/query/custom-message-dialog.tsx @@ -10,63 +10,62 @@ import { import { Form, FormControl, - FormDescription, FormField, FormItem, FormMessage, } from '@/components/ui/form' import { Textarea } from '@/components/ui/textarea' import { yupResolver } from '@hookform/resolvers/yup' -import { Info } from 'lucide-react' import { FC, useEffect } from 'react' import { useForm } from 'react-hook-form' import * as Yup from 'yup' -const CUSTOM_RESPONSE_MAX_LENGTH = 3000 +const CUSTOM_MESSAGE_MAX_LENGTH = 3000 -export const customResponseFormSchema = Yup.object({ - customResponse: Yup.string() +export const customMessageFormSchema = Yup.object({ + customMessage: Yup.string() .max( - CUSTOM_RESPONSE_MAX_LENGTH, + CUSTOM_MESSAGE_MAX_LENGTH, `The response can't be longer than 3000 characters`, ) .required('Please enter a response for the query'), }) -type CustomResponseFormValues = Yup.InferType<typeof customResponseFormSchema> +type CustomMessageFormValues = Yup.InferType<typeof customMessageFormSchema> -interface CustomResponseDialogProps { +interface CustomMessageDialogProps { initialValue: string isOpen: boolean title: string | JSX.Element description: string - onClose: (newCustomResponse?: string) => void + onClose: (newCustomMessage?: string) => void } -const CustomResponseDialog: FC<CustomResponseDialogProps> = ({ +const CustomMessageDialog: FC<CustomMessageDialogProps> = ({ initialValue, isOpen, title, description, onClose, }) => { - const form = useForm<CustomResponseFormValues>({ - resolver: yupResolver(customResponseFormSchema), + const form = useForm<CustomMessageFormValues>({ + resolver: yupResolver(customMessageFormSchema), defaultValues: { - customResponse: initialValue, + customMessage: initialValue, }, }) const handleCancel = () => { + form.reset({ customMessage: initialValue }) onClose() - form.reset({ customResponse: initialValue }) } - const handleContinue = (formValues: CustomResponseFormValues) => { - onClose(formValues.customResponse) + const handleContinue = (formValues: CustomMessageFormValues) => { + form.reset({ customMessage: initialValue }) + onClose(formValues.customMessage) } useEffect( - () => form.reset({ customResponse: initialValue }), + () => form.reset({ customMessage: initialValue }), [form, initialValue], ) @@ -86,17 +85,13 @@ const CustomResponseDialog: FC<CustomResponseDialogProps> = ({ </DialogHeader> <FormField control={form.control} - name="customResponse" + name="customMessage" render={({ field }) => ( <FormItem> <FormControl> - <Textarea {...field} /> + <Textarea {...field} rows={10} /> </FormControl> <FormMessage /> - <FormDescription className="flex items-start gap-1 pt-2"> - <Info size={18} strokeWidth={2}></Info> - {`This message will be sent as the question's response to the Slack thread.`} - </FormDescription> </FormItem> )} /> @@ -104,7 +99,7 @@ const CustomResponseDialog: FC<CustomResponseDialogProps> = ({ <Button variant="outline" type="button" onClick={handleCancel}> Cancel </Button> - <Button type="submit">Done</Button> + <Button type="submit">Save</Button> </DialogFooter> </form> </Form> @@ -112,4 +107,4 @@ const CustomResponseDialog: FC<CustomResponseDialogProps> = ({ </Dialog> ) } -export default CustomResponseDialog +export default CustomMessageDialog diff --git a/apps/ai/clients/admin-console/src/components/query/loading-sql-results.tsx b/apps/ai/clients/admin-console/src/components/query/loading-box.tsx similarity index 62% rename from apps/ai/clients/admin-console/src/components/query/loading-sql-results.tsx rename to apps/ai/clients/admin-console/src/components/query/loading-box.tsx index 120dfd30..e98f9a8c 100644 --- a/apps/ai/clients/admin-console/src/components/query/loading-sql-results.tsx +++ b/apps/ai/clients/admin-console/src/components/query/loading-box.tsx @@ -2,11 +2,9 @@ import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/utils' import { FC, HTMLAttributes } from 'react' -export type LoadingSqlQueryResultsProps = HTMLAttributes<HTMLDivElement> +export type LoadingBoxProps = HTMLAttributes<HTMLDivElement> -const LoadingSqlQueryResults: FC<LoadingSqlQueryResultsProps> = ({ - className, -}) => { +const LoadingBox: FC<LoadingBoxProps> = ({ className }) => { return ( <div className={cn( @@ -19,4 +17,4 @@ const LoadingSqlQueryResults: FC<LoadingSqlQueryResultsProps> = ({ ) } -export default LoadingSqlQueryResults +export default LoadingBox diff --git a/apps/ai/clients/admin-console/src/components/query/loading.tsx b/apps/ai/clients/admin-console/src/components/query/loading.tsx index f77bd43e..10c647ff 100644 --- a/apps/ai/clients/admin-console/src/components/query/loading.tsx +++ b/apps/ai/clients/admin-console/src/components/query/loading.tsx @@ -1,19 +1,28 @@ +import { SectionHeader } from '@/components/query/section-header' import { Skeleton } from '@/components/ui/skeleton' import { FC } from 'react' const LoadingQuery: FC = () => { return ( - <div className="w-full h-full bg-gray-50 rounded-lg flex flex-col gap-3"> - <div className="flex justify-between gap-5"> + <div className="w-full h-full flex flex-col gap-3"> + <div className="flex justify-between gap-32 p-6"> <div className="w-2/3 flex flex-col gap-1"> + <Skeleton className="h-10" /> + <Skeleton className="h-6" /> + </div> + <div className="w-1/3 flex flex-col gap-1"> + <Skeleton className="h-10" /> <Skeleton className="h-6" /> - <Skeleton className="h-4" /> </div> - <Skeleton className="w-1/3 h-11" /> </div> - <Skeleton className="h-1/2" /> - <Skeleton className="h-1/2" /> - <Skeleton className="h-6 w-2/3" /> + <SectionHeader> + <div className="h-10"></div> + </SectionHeader> + <Skeleton className="h-1/2 mx-6 my-3" /> + <SectionHeader> + <div className="h-10"></div> + </SectionHeader> + <Skeleton className="h-1/2 mx-6 my-3" /> </div> ) } diff --git a/apps/ai/clients/admin-console/src/components/query/message-section.tsx b/apps/ai/clients/admin-console/src/components/query/message-section.tsx new file mode 100644 index 00000000..1a488250 --- /dev/null +++ b/apps/ai/clients/admin-console/src/components/query/message-section.tsx @@ -0,0 +1,205 @@ +import CustomMessageDialog from '@/components/query/custom-message-dialog' +import LoadingBox from '@/components/query/loading-box' +import { + SectionHeader, + SectionHeaderTitle, +} from '@/components/query/section-header' +import SendMessageDialog from '@/components/query/send-message-dialog' +import { + MAIN_ACTION_BTN_CLASSES, + SECONDARY_ACTION_BTN_CLASSES, +} from '@/components/query/workspace' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { ToastAction } from '@/components/ui/toast' +import { toast } from '@/components/ui/use-toast' +import useQueryGenerateMessage from '@/hooks/api/query/useQueryGenerateMessage' +import { cn } from '@/lib/utils' +import { Bot, Edit, Loader } from 'lucide-react' +import Image from 'next/image' +import { FC, useState } from 'react' + +interface MessageSectionProps { + queryId: string + initialMessage: string + onPatchQuery: (data: { message: string }) => Promise<void> +} + +const MessageSection: FC<MessageSectionProps> = ({ + queryId, + initialMessage, + onPatchQuery, +}) => { + const generateMessage = useQueryGenerateMessage() + + const [currentMessage, setCurrentMessage] = useState<string>(initialMessage) + + const [openEditMessageDialog, setOpenEditMessageDialog] = useState(false) + const [editingMessage, setEditingMessage] = useState(false) + const [generatingMessage, setGeneratingMessage] = useState(false) + const handleGenerateMessage = async () => { + setGeneratingMessage(true) + try { + const { message } = await generateMessage(queryId) + toast({ + title: 'Message generated', + description: 'The query message was generated using the AI platform.', + }) + setCurrentMessage(message) + } catch (error) { + console.error(error) + } finally { + setGeneratingMessage(false) + } + } + const handleCloseEditDialog = async (newCustomMessage?: string) => { + setOpenEditMessageDialog(false) + if (!newCustomMessage) return + setEditingMessage(true) + try { + await onPatchQuery({ + message: newCustomMessage, + }) + setCurrentMessage(newCustomMessage) + toast({ + title: 'Message updated', + description: 'The query message was updated successfully.', + }) + } catch (error) { + console.error(error) + toast({ + variant: 'destructive', + title: 'Ups! Something went wrong.', + description: 'There was a problem with updating the message.', + action: ( + <ToastAction + altText="Try again" + onClick={() => handleCloseEditDialog(newCustomMessage)} + > + Try again + </ToastAction> + ), + }) + } finally { + setEditingMessage(false) + } + } + return ( + <> + <SectionHeader> + <SectionHeaderTitle> + <Image + src="/images/slack-black.png" + width={24} + height={24} + alt="Slack icon" + />{' '} + Slack message + </SectionHeaderTitle> + <div className="flex items-center gap-5"> + <div className="flex items-center gap-1"> + <Button + variant="ghost" + type="button" + className={cn( + MAIN_ACTION_BTN_CLASSES, + SECONDARY_ACTION_BTN_CLASSES, + )} + onClick={() => setOpenEditMessageDialog(true)} + > + <Edit size={14} strokeWidth={2}></Edit> + Edit + </Button> + <CustomMessageDialog + title={ + <div className="flex items-center gap-2"> + <Image + src="/images/slack-color.png" + width={24} + height={24} + alt="Slack icon" + />{' '} + Slack message + </div> + } + description="Compose the question's response message that will be sent to the Slack thread" + isOpen={openEditMessageDialog} + initialValue={currentMessage} + onClose={handleCloseEditDialog} + ></CustomMessageDialog> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button + variant="ghost" + type="button" + className={cn( + MAIN_ACTION_BTN_CLASSES, + SECONDARY_ACTION_BTN_CLASSES, + )} + > + {generatingMessage ? ( + <> + <Loader + className="mr-2 animate-spin" + size={16} + strokeWidth={2.5} + />{' '} + Generating + </> + ) : ( + <> + <Bot size={16} strokeWidth={2} /> + Generate + </> + )} + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle> + Generate natural language message + </AlertDialogTitle> + </AlertDialogHeader> + <AlertDialogDescription> + The AI will generate a response based on the last SQL query + run and results table. Make sure to run the correct SQL before + generating. + </AlertDialogDescription> + <AlertDialogDescription> + Do you wish to continue? + </AlertDialogDescription> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction onClick={handleGenerateMessage}> + <Bot className="mr-2" size={16} strokeWidth={2.5} /> + Generate + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + <SendMessageDialog {...{ queryId }} /> + </div> + </div> + </SectionHeader> + <div className="h-fit p-6 whitespace-pre-wrap"> + {editingMessage || generatingMessage ? ( + <LoadingBox className="h-24" /> + ) : ( + currentMessage + )} + </div> + </> + ) +} + +export default MessageSection diff --git a/apps/ai/clients/admin-console/src/components/query/query-metadata.tsx b/apps/ai/clients/admin-console/src/components/query/query-metadata.tsx new file mode 100644 index 00000000..4058bded --- /dev/null +++ b/apps/ai/clients/admin-console/src/components/query/query-metadata.tsx @@ -0,0 +1,66 @@ +import { + formatQueryStatusWithScore, + getDomainStatus, + getDomainStatusColors, +} from '@/lib/domain/query' +import { cn } from '@/lib/utils' +import { QueryStatus } from '@/models/api' +import { EDomainQueryStatus } from '@/models/domain' +import { Loader } from 'lucide-react' +import { FC, HTMLAttributes } from 'react' + +export interface QueryMetadataProps extends HTMLAttributes<HTMLDivElement> { + queryId: string + status: QueryStatus + confidenceLevel: number | null + updatingQuery: boolean + // onResubmit: () => void +} + +const QueryMetadata: FC<QueryMetadataProps> = ({ + queryId, + status, + confidenceLevel, + updatingQuery, + // onResubmit, + className, +}) => { + const textColor = getDomainStatusColors(status, confidenceLevel).text + const domainStatus = getDomainStatus(status, confidenceLevel) + const statusText = formatQueryStatusWithScore(domainStatus, confidenceLevel) + const isFromAI = [ + EDomainQueryStatus.LOW_CONFIDENCE.valueOf(), + EDomainQueryStatus.MEDIUM_CONFIDENCE.valueOf(), + EDomainQueryStatus.HIGH_CONFIDENCE.valueOf(), + ].includes(domainStatus as EDomainQueryStatus) + return ( + <div className={cn('flex flex-col gap-1 items-end', className)}> + <div className="flex items-center gap-2"> + {/* <Button + variant="ghost" + className="flex items-center gap-2 h-9" + onClick={onResubmit} + > + <Boxes /> Resubmit + </Button> */} + <h1 className="text-xl font-bold">{queryId}</h1> + </div> + <div + className={cn(textColor, 'flex flex-row items-center font-semibold')} + > + {updatingQuery ? ( + <div className="text-slate-500 flex items-center gap-2"> + <Loader className="animate-spin" size={20} /> Updating query... + </div> + ) : ( + <> + <div className="w-2 h-2 mr-2 rounded-full bg-current shrink-0" /> + {isFromAI ? `Not Verified - ` : ''} {statusText} + </> + )} + </div> + </div> + ) +} + +export default QueryMetadata diff --git a/apps/ai/clients/admin-console/src/components/query/question.tsx b/apps/ai/clients/admin-console/src/components/query/question.tsx index 8245e27d..5d374d9d 100644 --- a/apps/ai/clients/admin-console/src/components/query/question.tsx +++ b/apps/ai/clients/admin-console/src/components/query/question.tsx @@ -1,8 +1,9 @@ +import { cn } from '@/lib/utils' import { format } from 'date-fns' import { Calendar, Clock, User2 } from 'lucide-react' -import { FC } from 'react' +import { FC, HTMLAttributes } from 'react' -export interface QueryQuestionProps { +export interface QueryQuestionProps extends HTMLAttributes<HTMLDivElement> { username: string question: string questionDate: Date @@ -12,21 +13,22 @@ const QueryQuestion: FC<QueryQuestionProps> = ({ username, question, questionDate, + className, }) => ( - <div> - <h1 className="mb-1 font-bold">{question}</h1> - <h3 className="flex gap-5"> + <div className={cn('flex flex-col gap-2', className)}> + <h1 className="text-xl font-bold first-letter:capitalize">{question}</h1> + <div className="flex gap-5"> {[ { icon: User2, text: username }, { icon: Calendar, text: format(questionDate, 'MMMM dd, yyyy') }, { icon: Clock, text: format(questionDate, 'hh:mm a') }, ].map((item, index) => ( - <div key={index} className="flex items-center gap-1"> + <div key={index} className="flex items-center gap-2 text-sm"> <item.icon size={16} /> <span>{item.text}</span> </div> ))} - </h3> + </div> </div> ) diff --git a/apps/ai/clients/admin-console/src/components/query/section-header.tsx b/apps/ai/clients/admin-console/src/components/query/section-header.tsx new file mode 100644 index 00000000..619679ea --- /dev/null +++ b/apps/ai/clients/admin-console/src/components/query/section-header.tsx @@ -0,0 +1,13 @@ +import { FC, ReactNode } from 'react' + +export const SectionHeader: FC<{ children: ReactNode }> = ({ children }) => ( + <div className="bg-slate-200 w-full px-6 py-3 flex items-center justify-between gap-2"> + {children} + </div> +) + +export const SectionHeaderTitle: FC<{ children: ReactNode }> = ({ + children, +}) => ( + <div className="flex items-center gap-3 font-bold text-lg">{children}</div> +) diff --git a/apps/ai/clients/admin-console/src/components/query/send-message-dialog.tsx b/apps/ai/clients/admin-console/src/components/query/send-message-dialog.tsx new file mode 100644 index 00000000..d173d8de --- /dev/null +++ b/apps/ai/clients/admin-console/src/components/query/send-message-dialog.tsx @@ -0,0 +1,106 @@ +import { MAIN_ACTION_BTN_CLASSES } from '@/components/query/workspace' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { ToastAction } from '@/components/ui/toast' +import { toast } from '@/components/ui/use-toast' +import useQuerySendMessage from '@/hooks/api/query/useQuerySendMessage' +import { cn } from '@/lib/utils' +import { Loader, Send } from 'lucide-react' +import { FC, useState } from 'react' + +interface SendMessageDialogProps { + queryId: string +} + +const SendMessageDialog: FC<SendMessageDialogProps> = ({ queryId }) => { + const sendMessage = useQuerySendMessage() + const [sendingMessage, setSendingMessage] = useState(false) + const handleSendMessage = async () => { + try { + setSendingMessage(true) + await sendMessage(queryId) + toast({ + variant: 'success', + title: 'Message sent', + description: 'The query message was sent to the Slack thread.', + }) + } catch (error) { + console.error(error) + toast({ + variant: 'destructive', + title: 'Ups! Something went wrong.', + description: 'There was a problem with sending the message.', + action: ( + <ToastAction altText="Try again" onClick={handleSendMessage}> + Try again + </ToastAction> + ), + }) + } finally { + setSendingMessage(false) + } + } + return ( + <AlertDialog> + <AlertDialogTrigger asChild> + <Button + disabled={sendingMessage} + className={cn( + MAIN_ACTION_BTN_CLASSES, + 'bg-green-600 hover:bg-green-500', + )} + > + {sendingMessage ? ( + <> + <Loader + className="mr-2 animate-spin" + size={16} + strokeWidth={2.5} + />{' '} + Sending + </> + ) : ( + <> + <Send className="mr-2" size={16} strokeWidth={2.5} /> + Send + </> + )} + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Send Slack message?</AlertDialogTitle> + </AlertDialogHeader> + <AlertDialogDescription> + This will send the current message to the original question’s slack + thread and cannot be undone. + </AlertDialogDescription> + <AlertDialogDescription> + Do you wish to continue? + </AlertDialogDescription> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + className="bg-green-600 hover:bg-green-500" + onClick={handleSendMessage} + > + <Send className="mr-2" size={16} strokeWidth={2.5} /> + Send + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ) +} + +export default SendMessageDialog diff --git a/apps/ai/clients/admin-console/src/components/query/sql-editor.tsx b/apps/ai/clients/admin-console/src/components/query/sql-editor.tsx index 74bab007..8768e6b1 100644 --- a/apps/ai/clients/admin-console/src/components/query/sql-editor.tsx +++ b/apps/ai/clients/admin-console/src/components/query/sql-editor.tsx @@ -8,10 +8,15 @@ const Editor = dynamic(() => import('@monaco-editor/react'), { export interface SqlEditorProps { initialQuery: string + disabled?: boolean onValueChange: (value: string) => void } -const SqlEditor: FC<SqlEditorProps> = ({ initialQuery, onValueChange }) => { +const SqlEditor: FC<SqlEditorProps> = ({ + initialQuery, + onValueChange, + disabled = false, +}) => { const handleEditorChange = (value: string | undefined): void => { onValueChange(value || '') } @@ -57,10 +62,11 @@ const SqlEditor: FC<SqlEditorProps> = ({ initialQuery, onValueChange }) => { defaultValue={initialQuery} language="sql" options={{ + readOnly: disabled, lineHeight: 1.5, scrollBeyondLastLine: false, lineNumbersMinChars: 0, - renderLineHighlight: 'gutter', + renderLineHighlight: disabled ? 'none' : 'gutter', scrollbar: { useShadows: true, arrowSize: 0, diff --git a/apps/ai/clients/admin-console/src/components/query/verify-select.tsx b/apps/ai/clients/admin-console/src/components/query/verify-select.tsx deleted file mode 100644 index adc889a8..00000000 --- a/apps/ai/clients/admin-console/src/components/query/verify-select.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { - QUERY_STATUS_COLORS, - formatQueryStatus, - isNotVerified, - isRejected, - isVerified, -} from '@/lib/domain/query' -import { cn } from '@/lib/utils' -import { - EDomainQueryWorkspaceStatus, - QueryWorkspaceStatus, -} from '@/models/domain' -import { Ban, CheckCircle, XCircle } from 'lucide-react' -import { FC, useCallback } from 'react' - -export interface QueryVerifySelectProps { - verificationStatus: QueryWorkspaceStatus - onValueChange: (value: QueryWorkspaceStatus) => void -} - -const QueryVerifySelect: FC<QueryVerifySelectProps> = ({ - verificationStatus, - onValueChange, -}) => { - const handleValueChange = (value: QueryWorkspaceStatus) => { - onValueChange(value) - } - - const getStatusDisplay = useCallback( - (status: QueryWorkspaceStatus) => ( - <div - className={cn( - 'flex items-center gap-3 font-semibold text-base', - QUERY_STATUS_COLORS[status].text, - )} - > - {isVerified(status) && <CheckCircle size={20} strokeWidth={2.5} />} - {isNotVerified(status) && <XCircle size={20} strokeWidth={2.5} />} - {isRejected(status) && <Ban size={20} strokeWidth={3} />} - {formatQueryStatus(status)} - </div> - ), - [], - ) - - return ( - <Select onValueChange={handleValueChange}> - <SelectTrigger - className={cn( - 'w-[180px]', - QUERY_STATUS_COLORS[verificationStatus].border, - )} - > - <SelectValue placeholder={getStatusDisplay(verificationStatus)} /> - </SelectTrigger> - <SelectContent> - {Object.values(EDomainQueryWorkspaceStatus).map( - (qs: QueryWorkspaceStatus, idx) => ( - <SelectItem - key={qs + idx} - value={qs} - className={`focus:${QUERY_STATUS_COLORS[qs].background}`} - > - {getStatusDisplay(qs)} - </SelectItem> - ), - )} - </SelectContent> - </Select> - ) -} - -export default QueryVerifySelect diff --git a/apps/ai/clients/admin-console/src/components/query/workspace.tsx b/apps/ai/clients/admin-console/src/components/query/workspace.tsx index 295afb4d..5b23ba73 100644 --- a/apps/ai/clients/admin-console/src/components/query/workspace.tsx +++ b/apps/ai/clients/admin-console/src/components/query/workspace.tsx @@ -1,54 +1,46 @@ -import CustomResponseDialog from '@/components/query/custom-response-dialog' import QueryLastUpdated from '@/components/query/last-updated' -import LoadingSqlQueryResults from '@/components/query/loading-sql-results' -import QueryProcess from '@/components/query/process' +import LoadingBox from '@/components/query/loading-box' +import MessageSection from '@/components/query/message-section' +import QueryMetadata from '@/components/query/query-metadata' import QueryQuestion from '@/components/query/question' +import { + SectionHeader, + SectionHeaderTitle, +} from '@/components/query/section-header' import SqlEditor from '@/components/query/sql-editor' import SqlResultsTable from '@/components/query/sql-results-table' -import QueryVerifySelect from '@/components/query/verify-select' -import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Label } from '@/components/ui/label' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { ToastAction } from '@/components/ui/toast' import { Toaster } from '@/components/ui/toaster' import { useToast } from '@/components/ui/use-toast' +import { QueryPatchRequest } from '@/hooks/api/query/useQueryPatch' import { - QUERY_STATUS_BUTTONS_CLASSES, + QUERY_STATUS_COLORS, + QUERY_STATUS_EXPLANATION, + formatQueryStatus, isNotVerified, isRejected, - isSqlError, isVerified, } from '@/lib/domain/query' import { cn } from '@/lib/utils' -import { Query, QueryStatus } from '@/models/api' +import { Query } from '@/models/api' import { EDomainQueryWorkspaceStatus, QueryWorkspaceStatus, } from '@/models/domain' -import { - AlertCircle, - Ban, - Database, - Edit, - ListOrdered, - Loader, - Play, - Save, - Send, - XOctagon, -} from 'lucide-react' -import Image from 'next/image' -import Link from 'next/link' -import { FC, useEffect, useState } from 'react' +import { Ban, Box, Code2, Loader, Play, Verified, XOctagon } from 'lucide-react' +import { FC, useState } from 'react' + +export const MAIN_ACTION_BTN_CLASSES = 'h-8 py-0 px-4 w-28' +export const SECONDARY_ACTION_BTN_CLASSES = + 'w-fit px-4 text-sm hover:bg-slate-300 hover:text-black/80 flex items-center gap-1' export interface QueryWorkspaceProps { query: Query - onExecuteQuery: (sql_query: string) => void - onPatchQuery: (patches: { - sql_query: string - custom_response: string - query_status: QueryStatus - }) => void + onExecuteQuery: (sql_query: string) => Promise<void> + onPatchQuery: (patches: QueryPatchRequest) => Promise<void> } const QueryWorkspace: FC<QueryWorkspaceProps> = ({ @@ -57,48 +49,52 @@ const QueryWorkspace: FC<QueryWorkspaceProps> = ({ onPatchQuery, }) => { const { - id, + id: queryId, + display_id, question, question_date, - response, + response, // TODO delete this + message, username, sql_query, sql_query_result, sql_error_message, + evaluation_score, status, - ai_process, last_updated, updated_by, } = query const questionDate: Date = new Date(question_date) const lastUpdatedDate: Date = new Date(last_updated) + const [currentSqlQuery, setCurrentSqlQuery] = useState(sql_query) - const [verificationStatus, setVerifiedStatus] = useState<QueryStatus>(status) - const [customResponse, setCustomResponse] = useState<string>(response) - const [customResponseHasChanges, setcustomResponseHasChanges] = - useState(false) - const [openEditResponseDialog, setOpenEditResponseDialog] = useState(false) - const [savingQuery, setSavingQuery] = useState(false) - const [loadingSqlQueryResults, setLoadingQueryResults] = useState(false) + const [currentQueryStatus, setCurrentQueryStatus] = + useState<EDomainQueryWorkspaceStatus>( + Object.values(EDomainQueryWorkspaceStatus).find((qs) => qs === status) || + EDomainQueryWorkspaceStatus.NOT_VERIFIED, + ) + + const [runningQuery, setRunningQuery] = useState(false) + const [updatingQueryStatus, setUpdatingQueryStatus] = useState(false) const { toast } = useToast() const handleRunQuery = async () => { - setLoadingQueryResults(true) + setRunningQuery(true) try { await onExecuteQuery(currentSqlQuery) toast({ - title: 'Query executed', + title: 'Query updated', description: - 'The query was executed successfully. Results and natural language response updated.', + 'The query was executed successfully and the results were updated.', }) } catch (e) { console.error(e) toast({ variant: 'destructive', title: 'Ups! Something went wrong.', - description: 'There was a problem with running your query', + description: 'There was a problem with running the query', action: ( <ToastAction altText="Try again" onClick={handleRunQuery}> Try again @@ -106,52 +102,57 @@ const QueryWorkspace: FC<QueryWorkspaceProps> = ({ ), }) } finally { - setLoadingQueryResults(false) + setRunningQuery(false) } } - const handleSaveQuery = async () => { + const updateQueryStatus = async (newStatus: EDomainQueryWorkspaceStatus) => { + if (updatingQueryStatus) return + setUpdatingQueryStatus(true) try { - setSavingQuery(true) await onPatchQuery({ - query_status: verificationStatus, - custom_response: customResponse, - sql_query: currentSqlQuery, + query_status: newStatus, }) - if (isVerified(verificationStatus)) { + if (isVerified(newStatus)) { toast({ variant: 'success', title: 'Query Verified', description: - 'Response sent to the Slack thread and added to the Golden SQL training set.', + 'Query added to the Golden SQL training set and used to further train the model for future queries.', }) - } else if (isNotVerified(verificationStatus)) { + } else if (isNotVerified(newStatus)) { toast({ title: 'Query Unverified', - description: 'The query is not part of the Golden SQL training set.', + description: + 'The query is not part of the Golden SQL training set and not used to improve the platform accuracy.', }) - } else if (isRejected(verificationStatus)) { + } else if (isRejected(newStatus)) { toast({ variant: 'destructive-outline', title: 'Query Rejected', description: - 'Response sent to the Slack thread informing that this query could not be answered.', + 'The query is marked as rejected and will not be used to improve the platform accuracy.', }) } + setCurrentQueryStatus(newStatus) } catch (e) { console.error(e) + setCurrentQueryStatus(status as EDomainQueryWorkspaceStatus) toast({ variant: 'destructive', title: 'Ups! Something went wrong.', - description: 'There was a problem with saving your query', + description: 'There was a problem with updating the query status', action: ( - <ToastAction altText="Try again" onClick={handleSaveQuery}> + <ToastAction + altText="Try again" + onClick={() => updateQueryStatus(newStatus)} + > Try again </ToastAction> ), }) } finally { - setSavingQuery(false) + setUpdatingQueryStatus(false) } } @@ -159,227 +160,118 @@ const QueryWorkspace: FC<QueryWorkspaceProps> = ({ setCurrentSqlQuery(value) } - const handleVerifyChange = (verificationStatus: QueryWorkspaceStatus) => { - setVerifiedStatus(verificationStatus) - if (isRejected(verificationStatus) && !customResponseHasChanges) { - setOpenEditResponseDialog(true) - } - } - - const handleCloseEditDialog = (newCustomResponse = customResponse) => { - setCustomResponse(newCustomResponse) - setOpenEditResponseDialog(false) + const handleQueryStatusChange = (value: EDomainQueryWorkspaceStatus) => { + setCurrentQueryStatus(value) + updateQueryStatus(value) } - useEffect(() => { - setcustomResponseHasChanges(customResponse !== query.response) - }, [query.response, customResponse]) - - useEffect(() => { - setCustomResponse(query.response) - }, [query]) - - const rejectedBanner = ( - <div className="h-full flex items-center justify-center gap-2 text-muted-foreground"> - <Ban size={18} strokeWidth={2} /> Rejected query - </div> - ) + // TODO implement + // const handleResubmit = async () => { + // console.log('resubmitting query') + // } return ( <> <div - className="grow flex flex-col gap-5" - data-ph-capture-attribute-query_id={id} + className="grow flex flex-col gap-3 mt-4 overflow-auto" + data-ph-capture-attribute-query_id={queryId} data-ph-capture-attribute-asker={username} > - <div id="header" className="flex justify-between gap-3"> - <QueryQuestion {...{ username, question, questionDate }} /> - <div className="flex items-center self-start gap-5 min-w-fit"> - <Link href="/queries"> - <Button variant="link" className="font-normal"> - Cancel - </Button> - </Link> - <Button - className={cn(QUERY_STATUS_BUTTONS_CLASSES[verificationStatus])} - onClick={handleSaveQuery} - disabled={savingQuery} - > - {savingQuery ? ( - <> - <Loader - className="mr-2 animate-spin" - size={20} - strokeWidth={2.5} - />{' '} - Saving - </> - ) : ( - <> - {isVerified(verificationStatus) && ( - <> - <Send className="mr-2" size={20} strokeWidth={2.5} /> - Save and Send - </> - )} - {(isNotVerified(verificationStatus) || - isSqlError(verificationStatus)) && ( - <> - <Save className="mr-2" size={20} strokeWidth={2.5} /> - Save - </> - )} - {isRejected(verificationStatus) && ( - <> - <Send className="mr-2" size={20} strokeWidth={2.5} /> - Save and Send - </> - )} - </> - )} - </Button> - </div> + <div id="header" className="flex items-end justify-between gap-3 px-6"> + <QueryQuestion + className="max-w-2xl" + {...{ username, question, questionDate }} + /> + <QueryMetadata + {...{ + queryId: display_id, + status, + updatingQuery: runningQuery || updatingQueryStatus, + confidenceLevel: evaluation_score, + // onResubmit: handleResubmit, + }} + /> </div> - {loadingSqlQueryResults ? ( - <div className="shrink-0 h-24"> - <LoadingSqlQueryResults /> - </div> - ) : ( - customResponse && ( - <div className="bg-white flex flex-col px-5 pt-3 pb-5 rounded-xl border"> - <div className="flex items-center justify-between gap-3"> - <div className="flex items-center gap-2"> - <Image - src="/images/slack-white.png" - width={16} - height={16} - alt="Slack icon" - /> - <div className="font-bold mr-1">Slack response</div> - </div> - <Button - variant="link" - className="font-normal text-black flex items-center gap-1" - onClick={() => setOpenEditResponseDialog(true)} - > - <Edit size={18} strokeWidth={2}></Edit> - Edit - </Button> + <div className="grow flex flex-col overflow-auto"> + <SectionHeader> + <SectionHeaderTitle> + <Code2 strokeWidth={2}></Code2> + {isNotVerified(currentQueryStatus) ? 'Verify SQL' : 'SQL'} + </SectionHeaderTitle> + {isNotVerified(currentQueryStatus) && ( + <Button + onClick={handleRunQuery} + disabled={runningQuery || updatingQueryStatus} + className={cn(MAIN_ACTION_BTN_CLASSES)} + > + {runningQuery ? ( + <> + <Loader + className="mr-2 animate-spin" + size={16} + strokeWidth={2.5} + />{' '} + Running + </> + ) : ( + <> + <Play className="mr-2" size={16} strokeWidth={2.5} /> + Run + </> + )} + </Button> + )} + </SectionHeader> + <div className="grow flex flex-col gap-2 px-6 py-4"> + <div className="grow h-44"> + <SqlEditor + disabled={isVerified(currentQueryStatus)} + initialQuery={currentSqlQuery} + onValueChange={handleSqlChange} + /> + </div> + <QueryLastUpdated + responsible={updated_by?.name as string} + date={lastUpdatedDate} + /> + {runningQuery || updatingQueryStatus ? ( + <div id="loading_query_results" className="shrink-0 h-32"> + <LoadingBox /> </div> - <div className="break-words">{customResponse}</div> - {(isVerified(verificationStatus) || - isRejected(verificationStatus)) && ( - <Alert - variant="info" - className="flex items-center gap-2 mt-3 w-fit" - > - <div> - <AlertCircle size={18} /> + ) : isVerified(currentQueryStatus) ? ( + <div + id="verified_banner" + className="shrink-0 h-32 flex flex-col border bg-muted text-muted-foreground" + > + <div className="h-full flex flex-col items-center justify-center gap-2"> + <div className="flex items-center gap-2 text-green-700"> + <Verified size={18} strokeWidth={2} /> Verified query + </div> + <div className="px-20 text-center"> + {`The SQL query was verified and added to the Golden SQL training set. To modify the SQL query, please set the status to "Not Verified" first.`} </div> - <AlertDescription> - {`This message will be sent as the question's response to - the Slack thread each time you save.`} - </AlertDescription> - </Alert> - )} - </div> - ) - )} - <div - id="tabs" - className="shrink-0 h-80 grow flex-auto flex flex-col gap-5 bg-white border rounded-xl px-6 py-4" - > - <Tabs - defaultValue="sql" - className="w-full grow overflow-auto flex flex-col" - > - <TabsList className="w-full"> - <div className="w-full flex gap-3 justify-between py-2"> - <div id="tab-triggers" className="flex gap-5"> - <TabsTrigger value="sql"> - <Database className="mr-2" size={20} strokeWidth={2.5} />{' '} - SQL - </TabsTrigger> - <TabsTrigger value="process"> - <ListOrdered className="mr-2" size={20} strokeWidth={2.5} /> - Process - </TabsTrigger> </div> - <div id="actions" className="flex items-center gap-5"> - <span className="text-lg">Mark as </span> - <QueryVerifySelect - verificationStatus={ - isSqlError(verificationStatus) - ? EDomainQueryWorkspaceStatus.NOT_VERIFIED - : (verificationStatus as EDomainQueryWorkspaceStatus) - } - onValueChange={handleVerifyChange} - /> - <Button - onClick={handleRunQuery} - disabled={ - loadingSqlQueryResults || isRejected(verificationStatus) - } - > - {loadingSqlQueryResults ? ( - <> - <Loader - className="mr-2 animate-spin" - size={20} - strokeWidth={2.5} - />{' '} - Running - </> - ) : ( - <> - <Play className="mr-2" size={20} strokeWidth={2.5} /> - Run - </> - )} - </Button> + </div> + ) : isRejected(currentQueryStatus) ? ( + <div + id="rejected__banner" + className="shrink-0 h-32 flex flex-col border bg-muted text-red-500" + > + <div className="h-full flex items-center justify-center gap-2 "> + <Ban size={18} strokeWidth={2} /> Rejected query </div> </div> - </TabsList> - <TabsContent value="sql" className="pt-3 grow"> - {isRejected(verificationStatus) ? ( - rejectedBanner - ) : ( - <SqlEditor - initialQuery={currentSqlQuery} - onValueChange={handleSqlChange} - /> - )} - </TabsContent> - <TabsContent value="process" className="pt-3 grow overflow-auto"> - {isRejected(verificationStatus) ? ( - rejectedBanner - ) : ( - <QueryProcess processSteps={ai_process} /> - )} - </TabsContent> - </Tabs> - <QueryLastUpdated - responsible={updated_by?.name as string} - date={lastUpdatedDate} - /> - </div> - {loadingSqlQueryResults ? ( - <div className="shrink-0 h-32"> - <LoadingSqlQueryResults /> - </div> - ) : sql_error_message ? ( - <div className="shrink-0 h-60 flex flex-col items-center bg-white border border-red-600 text-red-600"> - <div className="flex items-center gap-3 py-5 font-bold"> - <XOctagon size={28} /> - <span>SQL Error</span> - </div> - <div className="w-full overflow-auto px-8 pb-3"> - {sql_error_message} - </div> - </div> - ) : ( - <> - {!isRejected(verificationStatus) && ( + ) : sql_error_message ? ( + <div className="shrink-0 h-32 flex flex-col items-center border bg-white border-red-600 text-red-600"> + <div className="flex items-center gap-3 py-5 font-bold"> + <XOctagon size={28} /> + <span>SQL Error</span> + </div> + <div className="w-full overflow-auto px-8 pb-3"> + {sql_error_message} + </div> + </div> + ) : ( <div id="query_results" className="min-h-[10rem] max-h-80 flex flex-col border bg-white" @@ -402,26 +294,61 @@ const QueryWorkspace: FC<QueryWorkspaceProps> = ({ )} </div> )} - </> - )} - </div> - <CustomResponseDialog - title={ - <div className="flex items-center gap-2"> - <Image - src="/images/slack-white.png" - width={20} - height={20} - alt="Slack icon" - />{' '} - Slack response </div> - } - description="Compose the question's response message that will be sent to the Slack thread" - isOpen={openEditResponseDialog} - initialValue={customResponse} - onClose={handleCloseEditDialog} - ></CustomResponseDialog> + <MessageSection + {...{ queryId, initialMessage: message || response, onPatchQuery }} + /> + <SectionHeader> + <SectionHeaderTitle> + <Box strokeWidth={2}></Box>Query Status + </SectionHeaderTitle> + </SectionHeader> + <div className="p-6 flex flex-col gap-5"> + <RadioGroup + disabled={runningQuery || updatingQueryStatus} + className="space-y-1" + value={currentQueryStatus} + onValueChange={handleQueryStatusChange} + > + {Object.values(EDomainQueryWorkspaceStatus).map( + (qs: QueryWorkspaceStatus, idx) => ( + <div + key={qs + idx} + className={cn( + 'flex items-center space-x-2', + QUERY_STATUS_COLORS[qs].text, + qs === currentQueryStatus && 'font-bold', + )} + > + <RadioGroupItem + value={qs} + id={qs} + className={cn( + QUERY_STATUS_COLORS[qs].text, + QUERY_STATUS_COLORS[qs].border, + )} + /> + <Label + htmlFor={qs} + className={cn( + 'tracking-wide text-base', + runningQuery || updatingQueryStatus + ? '' + : 'cursor-pointer', + )} + > + {formatQueryStatus(qs)}{' '} + <span className="ml-2 text-xs text-slate-400"> + {QUERY_STATUS_EXPLANATION[qs]} + </span> + </Label> + </div> + ), + )} + </RadioGroup> + </div> + </div> + </div> <Toaster /> </> ) diff --git a/apps/ai/clients/admin-console/src/components/ui/radio-group.tsx b/apps/ai/clients/admin-console/src/components/ui/radio-group.tsx new file mode 100644 index 00000000..d2b3f09f --- /dev/null +++ b/apps/ai/clients/admin-console/src/components/ui/radio-group.tsx @@ -0,0 +1,42 @@ +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' +import { Circle } from 'lucide-react' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const RadioGroup = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Root + className={cn('grid gap-2', className)} + {...props} + ref={ref} + /> + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Item + ref={ref} + className={cn( + 'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', + className, + )} + {...props} + > + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-2.5 w-2.5 fill-current text-current" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/apps/ai/clients/admin-console/src/hooks/api/useQueries.ts b/apps/ai/clients/admin-console/src/hooks/api/query/useQueries.ts similarity index 100% rename from apps/ai/clients/admin-console/src/hooks/api/useQueries.ts rename to apps/ai/clients/admin-console/src/hooks/api/query/useQueries.ts diff --git a/apps/ai/clients/admin-console/src/hooks/api/useQuery.ts b/apps/ai/clients/admin-console/src/hooks/api/query/useQuery.ts similarity index 100% rename from apps/ai/clients/admin-console/src/hooks/api/useQuery.ts rename to apps/ai/clients/admin-console/src/hooks/api/query/useQuery.ts diff --git a/apps/ai/clients/admin-console/src/hooks/api/useQueryExecution.ts b/apps/ai/clients/admin-console/src/hooks/api/query/useQueryExecution.ts similarity index 88% rename from apps/ai/clients/admin-console/src/hooks/api/useQueryExecution.ts rename to apps/ai/clients/admin-console/src/hooks/api/query/useQueryExecution.ts index 27ff8904..9cb5e967 100644 --- a/apps/ai/clients/admin-console/src/hooks/api/useQueryExecution.ts +++ b/apps/ai/clients/admin-console/src/hooks/api/query/useQueryExecution.ts @@ -8,7 +8,7 @@ const useQueryExecution = () => { const executeQuery = useCallback( async (queryId: string, sql_query: string): Promise<Query> => - apiFetcher<Query>(`${API_URL}/query/${queryId}/answer`, { + apiFetcher<Query>(`${API_URL}/query/${queryId}/sql-answer`, { method: 'POST', body: JSON.stringify({ sql_query }), }), diff --git a/apps/ai/clients/admin-console/src/hooks/api/query/useQueryGenerateMessage.ts b/apps/ai/clients/admin-console/src/hooks/api/query/useQueryGenerateMessage.ts new file mode 100644 index 00000000..59ca644e --- /dev/null +++ b/apps/ai/clients/admin-console/src/hooks/api/query/useQueryGenerateMessage.ts @@ -0,0 +1,26 @@ +import { API_URL } from '@/config' +import useApiFetcher from '@/hooks/api/generics/useApiFetcher' +import { useCallback } from 'react' + +interface GenerateMessageResponse { + message: string +} + +const useQueryGenerateMessage = () => { + const apiFetcher = useApiFetcher() + + const generateMessage = useCallback( + async (queryId: string): Promise<GenerateMessageResponse> => + apiFetcher<GenerateMessageResponse>( + `${API_URL}/query/${queryId}/message`, + { + method: 'PATCH', + body: JSON.stringify({}), + }, + ), + [apiFetcher], + ) + return generateMessage +} + +export default useQueryGenerateMessage diff --git a/apps/ai/clients/admin-console/src/hooks/api/useQueryPatch.ts b/apps/ai/clients/admin-console/src/hooks/api/query/useQueryPatch.ts similarity index 64% rename from apps/ai/clients/admin-console/src/hooks/api/useQueryPatch.ts rename to apps/ai/clients/admin-console/src/hooks/api/query/useQueryPatch.ts index b894a348..830e578e 100644 --- a/apps/ai/clients/admin-console/src/hooks/api/useQueryPatch.ts +++ b/apps/ai/clients/admin-console/src/hooks/api/query/useQueryPatch.ts @@ -3,18 +3,16 @@ import useApiFetcher from '@/hooks/api/generics/useApiFetcher' import { Query, QueryStatus } from '@/models/api' import { useCallback } from 'react' -const usePatchQuery = () => { +export interface QueryPatchRequest { + query_status?: QueryStatus + message?: string +} + +const useQueryPatch = () => { const apiFetcher = useApiFetcher() const patchQuery = useCallback( - async ( - queryId: string, - patches: { - sql_query: string - custom_response: string - query_status: QueryStatus - }, - ): Promise<Query> => + async (queryId: string, patches: QueryPatchRequest): Promise<Query> => apiFetcher<Query>(`${API_URL}/query/${queryId}`, { method: 'PATCH', body: JSON.stringify(patches), @@ -24,4 +22,4 @@ const usePatchQuery = () => { return patchQuery } -export default usePatchQuery +export default useQueryPatch diff --git a/apps/ai/clients/admin-console/src/hooks/api/query/useQuerySendMessage.ts b/apps/ai/clients/admin-console/src/hooks/api/query/useQuerySendMessage.ts new file mode 100644 index 00000000..d7e924cb --- /dev/null +++ b/apps/ai/clients/admin-console/src/hooks/api/query/useQuerySendMessage.ts @@ -0,0 +1,18 @@ +import { API_URL } from '@/config' +import useApiFetcher from '@/hooks/api/generics/useApiFetcher' +import { useCallback } from 'react' + +const useQuerySendMessage = () => { + const apiFetcher = useApiFetcher() + + const sendMessage = useCallback( + async (queryId: string): Promise<void> => + apiFetcher<void>(`${API_URL}/query/${queryId}/message`, { + method: 'POST', + }), + [apiFetcher], + ) + return sendMessage +} + +export default useQuerySendMessage diff --git a/apps/ai/clients/admin-console/src/lib/domain/query.ts b/apps/ai/clients/admin-console/src/lib/domain/query.ts index 9b60a401..7c4f16e1 100644 --- a/apps/ai/clients/admin-console/src/lib/domain/query.ts +++ b/apps/ai/clients/admin-console/src/lib/domain/query.ts @@ -37,6 +37,14 @@ export const QUERY_STATUS_COLORS: ResourceColors<QueryWorkspaceStatus> = { }, } +export const QUERY_STATUS_EXPLANATION: Record<QueryWorkspaceStatus, string> = { + [EQueryStatus.REJECTED]: `The question is invalid, such as in the case of insufficient data or an unanswerable question`, + [EQueryStatus.NOT_VERIFIED]: + 'The query is not used to improve the platform accuracy', + [EQueryStatus.VERIFIED]: + 'The query is part of the Golden SQL training set to improve the platform accuracy', +} + export const DOMAIN_QUERY_STATUS_COLORS: ResourceColors<DomainQueryStatus> = { [EDomainQueryStatus.REJECTED]: { text: 'text-red-500', @@ -44,6 +52,9 @@ export const DOMAIN_QUERY_STATUS_COLORS: ResourceColors<DomainQueryStatus> = { [EDomainQueryStatus.SQL_ERROR]: { text: 'text-red-500', }, + [EDomainQueryStatus.NOT_VERIFIED]: { + text: 'text-gray-500', + }, [EDomainQueryStatus.LOW_CONFIDENCE]: { text: 'text-orange-600', }, @@ -60,7 +71,7 @@ export const DOMAIN_QUERY_STATUS_COLORS: ResourceColors<DomainQueryStatus> = { export const getDomainStatus = ( status: QueryStatus, - evaluation_score: number, + evaluation_score: number | null, ): DomainQueryStatus | undefined => { switch (status) { case EQueryStatus.REJECTED: @@ -68,7 +79,9 @@ export const getDomainStatus = ( case EQueryStatus.SQL_ERROR: return EDomainQueryStatus.SQL_ERROR case EQueryStatus.NOT_VERIFIED: { - if (evaluation_score < 70) { + if (evaluation_score === null) { + return status as DomainQueryStatus + } else if (evaluation_score < 70) { return EDomainQueryStatus.LOW_CONFIDENCE } else if (evaluation_score < 90) { return EDomainQueryStatus.MEDIUM_CONFIDENCE @@ -83,7 +96,7 @@ export const getDomainStatus = ( export const getDomainStatusColors = ( status: QueryStatus, - evaluation_score: number, + evaluation_score: number | null, ): ColorClasses => { const domainStatus = getDomainStatus( status, @@ -107,10 +120,13 @@ export const formatQueryStatus = ( export const formatQueryStatusWithScore = ( status: DomainQueryStatus | QueryStatus | undefined, - evaluation_score: number, + evaluation_score: number | null, ): string => { const formattedStatus = formatQueryStatus(status) - if (status === EQueryStatus.SQL_ERROR) { + if ( + status === EQueryStatus.SQL_ERROR || + (status === EQueryStatus.NOT_VERIFIED && evaluation_score === null) + ) { return formattedStatus } else if (status === EQueryStatus.REJECTED) { return formattedStatus + ' by Admin' diff --git a/apps/ai/clients/admin-console/src/models/api.ts b/apps/ai/clients/admin-console/src/models/api.ts index ffba2146..e206c9c4 100644 --- a/apps/ai/clients/admin-console/src/models/api.ts +++ b/apps/ai/clients/admin-console/src/models/api.ts @@ -82,15 +82,19 @@ export type QueryList = QueryListItem[] export interface Query { id: string + display_id: string + question_id: string question: string question_date: string + answer_id: string sql_query: string sql_query_result: QuerySqlResult | null sql_error_message?: string + evaluation_score: number ai_process: string[] - response: string + message: string + response: string // TODO remove this field once the backend migrated to `message` status: QueryStatus - evaluation_score: number username: string last_updated: string updated_by: User diff --git a/apps/ai/clients/admin-console/src/models/domain.ts b/apps/ai/clients/admin-console/src/models/domain.ts index 9f7d61e1..2474d4fb 100644 --- a/apps/ai/clients/admin-console/src/models/domain.ts +++ b/apps/ai/clients/admin-console/src/models/domain.ts @@ -4,6 +4,7 @@ import { LucideIcon } from 'lucide-react' export enum EDomainQueryStatus { REJECTED = 'REJECTED', SQL_ERROR = 'SQL_ERROR', + NOT_VERIFIED = 'NOT_VERIFIED', LOW_CONFIDENCE = 'LOW_CONFIDENCE', MEDIUM_CONFIDENCE = 'MEDIUM_CONFIDENCE', HIGH_CONFIDENCE = 'HIGH_CONFIDENCE', @@ -13,9 +14,9 @@ export enum EDomainQueryStatus { export type DomainQueryStatus = keyof typeof EDomainQueryStatus export enum EDomainQueryWorkspaceStatus { - REJECTED = 'REJECTED', - NOT_VERIFIED = 'NOT_VERIFIED', VERIFIED = 'VERIFIED', + NOT_VERIFIED = 'NOT_VERIFIED', + REJECTED = 'REJECTED', } export type QueryWorkspaceStatus = keyof typeof EDomainQueryWorkspaceStatus diff --git a/apps/ai/clients/admin-console/src/pages/databases/index.tsx b/apps/ai/clients/admin-console/src/pages/databases/index.tsx index b0e0f7c6..d573f8fe 100644 --- a/apps/ai/clients/admin-console/src/pages/databases/index.tsx +++ b/apps/ai/clients/admin-console/src/pages/databases/index.tsx @@ -66,7 +66,7 @@ const DatabasesPage: FC = () => { } return ( <PageLayout> - <ContentBox>{pageContent}</ContentBox> + <ContentBox className="m-6">{pageContent}</ContentBox> </PageLayout> ) } diff --git a/apps/ai/clients/admin-console/src/pages/golden-sql/index.tsx b/apps/ai/clients/admin-console/src/pages/golden-sql/index.tsx index eccdbba5..b327848b 100644 --- a/apps/ai/clients/admin-console/src/pages/golden-sql/index.tsx +++ b/apps/ai/clients/admin-console/src/pages/golden-sql/index.tsx @@ -81,7 +81,7 @@ const GoldenSQLPage: FC = () => { return ( <PageLayout> - <ContentBox className="overflow-auto"> + <ContentBox className="overflow-auto m-6"> <div className="flex flex-col gap-3 bg-gray-50 py-0"> <h1 className="font-bold">Training Queries</h1> <p className="text-sm"> diff --git a/apps/ai/clients/admin-console/src/pages/my-account/index.tsx b/apps/ai/clients/admin-console/src/pages/my-account/index.tsx index 66764475..576d1c40 100644 --- a/apps/ai/clients/admin-console/src/pages/my-account/index.tsx +++ b/apps/ai/clients/admin-console/src/pages/my-account/index.tsx @@ -13,7 +13,7 @@ const MyAccountPage: FC = () => { return ( <PageLayout> {user && ( - <div className="grid grid-cols-2 gap-4"> + <div className="grid grid-cols-2 gap-4 m-6"> <ContentBox> <div className="flex items-center gap-2"> <User2 size={18} strokeWidth={2.5} /> diff --git a/apps/ai/clients/admin-console/src/pages/organization-settings/index.tsx b/apps/ai/clients/admin-console/src/pages/organization-settings/index.tsx index 0ea7d35e..84b3ad47 100644 --- a/apps/ai/clients/admin-console/src/pages/organization-settings/index.tsx +++ b/apps/ai/clients/admin-console/src/pages/organization-settings/index.tsx @@ -19,7 +19,7 @@ const OrganizationSettingsPage: FC = () => { return ( <PageLayout> - <div className="flex flex-col gap-5"> + <div className="flex flex-col gap-5 m-6"> <div className="flex items-center gap-5"> <div className="flex items-center gap-2"> <Building2 size={18} /> diff --git a/apps/ai/clients/admin-console/src/pages/queries/[queryId]/index.tsx b/apps/ai/clients/admin-console/src/pages/queries/[queryId]/index.tsx index 2a11d01f..51637389 100644 --- a/apps/ai/clients/admin-console/src/pages/queries/[queryId]/index.tsx +++ b/apps/ai/clients/admin-console/src/pages/queries/[queryId]/index.tsx @@ -2,11 +2,12 @@ import PageLayout from '@/components/layout/page-layout' import QueryError from '@/components/query/error' import LoadingQuery from '@/components/query/loading' import QueryWorkspace from '@/components/query/workspace' -import { ContentBox } from '@/components/ui/content-box' -import { useQuery } from '@/hooks/api/useQuery' -import useQueryExecution from '@/hooks/api/useQueryExecution' -import usePatchQuery from '@/hooks/api/useQueryPatch' -import { Query, QueryStatus } from '@/models/api' +import { useQuery } from '@/hooks/api/query/useQuery' +import useQueryExecution from '@/hooks/api/query/useQueryExecution' +import useQueryPatch, { + QueryPatchRequest, +} from '@/hooks/api/query/useQueryPatch' +import { Query } from '@/models/api' import { withPageAuthRequired } from '@auth0/nextjs-auth0/client' import { useRouter } from 'next/router' import { FC, useEffect, useState } from 'react' @@ -21,24 +22,32 @@ const QueryPage: FC = () => { mutate, } = useQuery(queryId as string) const [query, setQuery] = useState<Query | undefined>(initialQuery) - const patchQuery = usePatchQuery() + const patchQuery = useQueryPatch() const executeQuery = useQueryExecution() useEffect(() => setQuery(initialQuery), [initialQuery]) const handleExecuteQuery = async (sql_query: string) => { - const executedQuery = await executeQuery(queryId as string, sql_query) - setQuery(executedQuery) + try { + const executedQuery = await executeQuery(queryId as string, sql_query) + setQuery(executedQuery) + } catch (e) { + console.error(e) + throw e + } + return void 0 } - const handlePatchQuery = async (patches: { - sql_query: string - custom_response: string - query_status: QueryStatus - }) => { - const patchedQuery = await patchQuery(queryId as string, patches) - mutate(patchedQuery) - setQuery(patchedQuery) + const handlePatchQuery = async (patches: QueryPatchRequest) => { + try { + const patchedQuery = await patchQuery(queryId as string, patches) + mutate(patchedQuery) + setQuery(patchedQuery) + } catch (e) { + console.error(e) + throw e + } + return void 0 } let pageContent: JSX.Element = <></> @@ -46,7 +55,11 @@ const QueryPage: FC = () => { if (isLoadingInitialQuery && !query) { pageContent = <LoadingQuery /> } else if (error) { - pageContent = <QueryError /> + pageContent = ( + <div className="m-6"> + <QueryError /> + </div> + ) } else if (query) pageContent = ( <QueryWorkspace @@ -56,11 +69,7 @@ const QueryPage: FC = () => { /> ) - return ( - <PageLayout> - <ContentBox>{pageContent}</ContentBox> - </PageLayout> - ) + return <PageLayout disableBreadcrumb>{pageContent}</PageLayout> } export default withPageAuthRequired(QueryPage) diff --git a/apps/ai/clients/admin-console/src/pages/queries/index.tsx b/apps/ai/clients/admin-console/src/pages/queries/index.tsx index 90a2bf4b..92c47eff 100644 --- a/apps/ai/clients/admin-console/src/pages/queries/index.tsx +++ b/apps/ai/clients/admin-console/src/pages/queries/index.tsx @@ -5,7 +5,7 @@ import { columns as cols } from '@/components/queries/columns' import QueriesError from '@/components/queries/error' import { Button } from '@/components/ui/button' import { ContentBox } from '@/components/ui/content-box' -import useQueries from '@/hooks/api/useQueries' +import useQueries from '@/hooks/api/query/useQueries' import { buildIdHref, cn } from '@/lib/utils' import { QueryListItem } from '@/models/api' import { withPageAuthRequired } from '@auth0/nextjs-auth0/client' @@ -63,7 +63,7 @@ const QueriesPage: FC = () => { return ( <PageLayout> - <ContentBox className="overflow-auto"> + <ContentBox className="overflow-auto m-6"> <div className="flex items-center justify-between bg-gray-50 py-0"> <h1 className="font-bold">Latest Queries</h1> <Button diff --git a/apps/ai/server/app.py b/apps/ai/server/app.py index 9cde0464..d3137dac 100644 --- a/apps/ai/server/app.py +++ b/apps/ai/server/app.py @@ -61,7 +61,7 @@ async def heartbeat(): @app.get("/engine/heartbeat") async def engine_heartbeat(): async with httpx.AsyncClient() as client: - response = await client.get(settings.k2_core_url + "/heartbeat") + response = await client.get(settings.engine_url + "/heartbeat") response.raise_for_status() # Raise an exception for non-2xx status codes return response.json() diff --git a/apps/ai/server/config.py b/apps/ai/server/config.py index 03e1498f..93024bf2 100644 --- a/apps/ai/server/config.py +++ b/apps/ai/server/config.py @@ -22,8 +22,8 @@ class Settings(BaseSettings): load_dotenv() - k2_core_url: str = os.environ.get("K2_CORE_URL") - default_k2_core_timeout: int = os.environ.get("DEFAULT_K2_TIMEOUT") + engine_url: str = os.environ.get("K2_CORE_URL") + default_engine_timeout: int = os.environ.get("DEFAULT_K2_TIMEOUT") encrypt_key: str = os.environ.get("ENCRYPT_KEY") def __getitem__(self, key: str) -> Any: diff --git a/apps/ai/server/database/migrations/sprint_62/1_DH-4875_query_edit_refactor.py b/apps/ai/server/database/migrations/sprint_62/1_DH-4875_query_edit_refactor.py new file mode 100644 index 00000000..5a1c5928 --- /dev/null +++ b/apps/ai/server/database/migrations/sprint_62/1_DH-4875_query_edit_refactor.py @@ -0,0 +1,21 @@ +import pymongo + +import config + +if __name__ == "__main__": + data_store = pymongo.MongoClient(config.db_settings.mongodb_uri)[ + config.db_settings.mongodb_db_name + ] + + try: + data_store["queries"].update_many( + {}, + { + "$rename": { + "custom_response": "message", + "response_id": "answer_id", + } + }, + ) + except Exception as e: + print(e) diff --git a/apps/ai/server/database/migrations/sprint_62/DH-4904_replace_llm_credentials.py b/apps/ai/server/database/migrations/sprint_62/2_DH-4904_replace_llm_credentials.py similarity index 100% rename from apps/ai/server/database/migrations/sprint_62/DH-4904_replace_llm_credentials.py rename to apps/ai/server/database/migrations/sprint_62/2_DH-4904_replace_llm_credentials.py diff --git a/apps/ai/server/dataherald b/apps/ai/server/dataherald index 844491de..6d40e930 160000 --- a/apps/ai/server/dataherald +++ b/apps/ai/server/dataherald @@ -1 +1 @@ -Subproject commit 844491de1eade136de6c516bbf1c0c3bc0890e4d +Subproject commit 6d40e930bc15dd9ac584caaba657f36b2ef7894e diff --git a/apps/ai/server/modules/db_connection/service.py b/apps/ai/server/modules/db_connection/service.py index 27209ab2..58f1b5bf 100644 --- a/apps/ai/server/modules/db_connection/service.py +++ b/apps/ai/server/modules/db_connection/service.py @@ -56,12 +56,12 @@ async def add_db_connection( async with httpx.AsyncClient() as client: response = await client.post( - settings.k2_core_url + "/database-connections", + settings.engine_url + "/database-connections", json={ "llm_api_key": organization.llm_api_key, **db_connection_request.dict(), }, - timeout=settings.default_k2_core_timeout, + timeout=settings.default_engine_timeout, ) raise_for_status(response.status_code, response.text) @@ -96,12 +96,12 @@ async def update_db_connection( async with httpx.AsyncClient() as client: response = await client.put( - settings.k2_core_url + f"/database-connections/{id}", + settings.engine_url + f"/database-connections/{id}", json={ "llm_api_key": organization.llm_api_key, **db_connection_request.dict(), }, - timeout=settings.default_k2_core_timeout, + timeout=settings.default_engine_timeout, ) raise_for_status(response.status_code, response.text) return DBConnectionResponse(**response.json()) diff --git a/apps/ai/server/modules/golden_sql/controller.py b/apps/ai/server/modules/golden_sql/controller.py index 6ce73181..4423cea2 100644 --- a/apps/ai/server/modules/golden_sql/controller.py +++ b/apps/ai/server/modules/golden_sql/controller.py @@ -6,6 +6,7 @@ from modules.golden_sql.models.requests import GoldenSQLRequest from modules.golden_sql.models.responses import GoldenSQLResponse from modules.golden_sql.service import GoldenSQLService +from modules.query.models.entities import QueryStatus from utils.auth import Authorize, VerifyToken router = APIRouter( @@ -56,4 +57,4 @@ async def add_user_upload_golden_sql( async def delete_golden_sql(id: str, token: str = Depends(token_auth_scheme)): org_id = authorize.user(VerifyToken(token.credentials).verify()).organization_id authorize.golden_sql_in_organization(id, org_id) - return await golden_sql_service.delete_golden_sql(id) + return await golden_sql_service.delete_golden_sql(id, QueryStatus.NOT_VERIFIED) diff --git a/apps/ai/server/modules/golden_sql/repository.py b/apps/ai/server/modules/golden_sql/repository.py index 11ab59ca..96825192 100644 --- a/apps/ai/server/modules/golden_sql/repository.py +++ b/apps/ai/server/modules/golden_sql/repository.py @@ -55,12 +55,16 @@ def delete_golden_sql_ref(self, golden_id: str) -> int: # this violates the architecture, but it's a quick fix for now # TODO: need to avoid cross resource dependency and avoid circular dependency def delete_verified_golden_sql_ref(self, query_id: str): - MongoDB.update_one( + return MongoDB.delete_one(GOLDEN_SQL_REF_COL, {"query_id": ObjectId(query_id)}) + + def update_query_status(self, query_id: str, status: str): + # this violates the architecture, but it's a quick fix for now + # TODO: need to avoid cross resource dependency and avoid circular dependency + return MongoDB.update_one( QUERY_RESPONSE_REF_COL, {"_id": ObjectId(query_id)}, - {"status": "NOT_VERIFIED"}, + {"status": status}, ) - return MongoDB.delete_one(GOLDEN_SQL_REF_COL, {"query_id": ObjectId(query_id)}) def get_next_display_id(self, org_id: str) -> str: return get_next_display_id(GOLDEN_SQL_REF_COL, ObjectId(org_id), "GS") diff --git a/apps/ai/server/modules/golden_sql/service.py b/apps/ai/server/modules/golden_sql/service.py index 78e68c6d..7507d262 100644 --- a/apps/ai/server/modules/golden_sql/service.py +++ b/apps/ai/server/modules/golden_sql/service.py @@ -10,6 +10,7 @@ from modules.golden_sql.models.requests import GoldenSQLRequest from modules.golden_sql.models.responses import GoldenSQLResponse from modules.golden_sql.repository import GoldenSQLRepository +from modules.query.models.entities import QueryStatus from utils.exception import raise_for_status @@ -65,9 +66,9 @@ async def add_verified_query_golden_sql( await self.delete_golden_sql(golden_sql_ref.golden_sql_id) async with httpx.AsyncClient() as client: response = await client.post( - settings.k2_core_url + "/golden-records", + settings.engine_url + "/golden-records", json=[golden_sql_request.dict()], - timeout=settings.default_k2_core_timeout, + timeout=settings.default_engine_timeout, ) raise_for_status(response.status_code, response.text) response_json = response.json()[0] @@ -94,12 +95,12 @@ async def add_user_upload_golden_sql( ) -> List[GoldenSQLResponse]: async with httpx.AsyncClient() as client: response = await client.post( - settings.k2_core_url + "/golden-records", + settings.engine_url + "/golden-records", json=[ golden_sql_request.dict() for golden_sql_request in golden_sql_requests ], - timeout=settings.default_k2_core_timeout, + timeout=settings.default_engine_timeout, ) raise_for_status(response.status_code, response.text) @@ -117,7 +118,7 @@ async def add_user_upload_golden_sql( golden_sql_ref_data = GoldenSQLRef( golden_sql_id=golden_sql.id, organization_id=ObjectId(org_id), - source=GoldenSQLSource.USER_UPLOAD.value, + source=GoldenSQLSource.USER_UPLOAD, created_time=datetime.now(timezone.utc).strftime( "%Y-%m-%d %H:%M:%S" ), @@ -133,14 +134,16 @@ async def add_user_upload_golden_sql( return golden_sql_responses - async def delete_golden_sql(self, golden_id: str) -> dict: + async def delete_golden_sql( + self, golden_id: str, query_status: QueryStatus = None + ) -> dict: golden_sql_ref = self.repo.get_golden_sql_ref(golden_id) if golden_sql_ref: async with httpx.AsyncClient() as client: response = await client.delete( - settings.k2_core_url + f"/golden-records/{golden_id}", - timeout=settings.default_k2_core_timeout, + settings.engine_url + f"/golden-records/{golden_id}", + timeout=settings.default_engine_timeout, ) raise_for_status(response.status_code, response.text) if response.json()["status"]: @@ -148,6 +151,10 @@ async def delete_golden_sql(self, golden_id: str) -> dict: matched_count = self.repo.delete_verified_golden_sql_ref( golden_sql_ref.query_id ) + if query_status: + self.repo.update_query_status( + golden_sql_ref.query_id, query_status + ) else: matched_count = self.repo.delete_golden_sql_ref(golden_id) if matched_count == 1: diff --git a/apps/ai/server/modules/instruction/service.py b/apps/ai/server/modules/instruction/service.py index de9304cc..62edc696 100644 --- a/apps/ai/server/modules/instruction/service.py +++ b/apps/ai/server/modules/instruction/service.py @@ -13,7 +13,7 @@ async def get_instructions( ) -> list[InstructionResponse]: async with httpx.AsyncClient() as client: response = await client.get( - settings.k2_core_url + "/instructions", + settings.engine_url + "/instructions", params={"db_connection_id": db_connection_id}, ) raise_for_status(response.status_code, response.text) @@ -22,7 +22,7 @@ async def get_instructions( async def get_instruction(self, db_connection_id: str) -> InstructionResponse: async with httpx.AsyncClient() as client: response = await client.get( - settings.k2_core_url + "/instructions", + settings.engine_url + "/instructions", params={"db_connection_id": db_connection_id}, ) instructions = response.json() @@ -36,7 +36,7 @@ async def add_instruction( ) -> InstructionResponse: async with httpx.AsyncClient() as client: response = await client.post( - settings.k2_core_url + "/instructions", + settings.engine_url + "/instructions", json={ "db_connection_id": db_connection_id, **instruction_request.dict(), @@ -53,7 +53,7 @@ async def update_instruction( ) -> InstructionResponse: async with httpx.AsyncClient() as client: response = await client.put( - settings.k2_core_url + f"/instructions/{instruction_id}", + settings.engine_url + f"/instructions/{instruction_id}", json={ "db_connection_id": db_connection_id, **instruction_request.dict(exclude_unset=True), @@ -65,7 +65,7 @@ async def update_instruction( async def delete_instruction(self, instruction_id): async with httpx.AsyncClient() as client: response = await client.delete( - settings.k2_core_url + f"/instructions/{instruction_id}", + settings.engine_url + f"/instructions/{instruction_id}", ) raise_for_status(response.status_code, response.text) return {"id": instruction_id} diff --git a/apps/ai/server/modules/query/controller.py b/apps/ai/server/modules/query/controller.py index 658974af..509b4951 100644 --- a/apps/ai/server/modules/query/controller.py +++ b/apps/ai/server/modules/query/controller.py @@ -3,11 +3,12 @@ from modules.organization.service import OrganizationService from modules.query.models.requests import ( - QueryExecutionRequest, QueryUpdateRequest, QuestionRequest, + SQLAnswerRequest, ) from modules.query.models.responses import ( + MessageResponse, QueryListResponse, QueryResponse, QuerySlackResponse, @@ -66,7 +67,7 @@ async def get_query(id: str, token: str = Depends(token_auth_scheme)) -> QueryRe @router.patch("/{id}") -async def patch_response( +async def update_query( id: str, query_request: QueryUpdateRequest, token: str = Depends(token_auth_scheme), @@ -74,15 +75,36 @@ async def patch_response( user = authorize.user(VerifyToken(token.credentials).verify()) organization = authorize.get_organization_by_user_response(user) authorize.query_in_organization(id, str(organization.id)) - return await query_service.patch_response(id, query_request, organization, user) + return await query_service.update_query(id, query_request, user, organization) -@router.post("/{id}/answer") -async def run_response( +@router.post("/{id}/sql-answer", status_code=status.HTTP_201_CREATED) +async def generate_sql_answer( id: str, - sql_query: QueryExecutionRequest, + sql_answer_request: SQLAnswerRequest, token: str = Depends(token_auth_scheme), ) -> QueryResponse: user = authorize.user(VerifyToken(token.credentials).verify()) authorize.query_in_organization(id, user.organization_id) - return await query_service.run_response(id, sql_query, user) + return await query_service.generate_sql_answer(id, sql_answer_request, user) + + +@router.patch("/{id}/message", status_code=status.HTTP_200_OK) +async def generate_message( + id: str, + token: str = Depends(token_auth_scheme), +) -> MessageResponse: + user = authorize.user(VerifyToken(token.credentials).verify()) + authorize.query_in_organization(id, user.organization_id) + return await query_service.generate_message(id) + + +@router.post("/{id}/message", status_code=status.HTTP_200_OK) +async def send_message( + id: str, + token: str = Depends(token_auth_scheme), +): + user = authorize.user(VerifyToken(token.credentials).verify()) + authorize.query_in_organization(id, user.organization_id) + organization = authorize.get_organization_by_user_response(user) + return await query_service.send_message(id, organization) diff --git a/apps/ai/server/modules/query/models/entities.py b/apps/ai/server/modules/query/models/entities.py index cb16cc98..d9879519 100644 --- a/apps/ai/server/modules/query/models/entities.py +++ b/apps/ai/server/modules/query/models/entities.py @@ -19,24 +19,24 @@ class QueryStatus(str, Enum): REJECTED = "REJECTED" -class SQLGenerationStatus(Enum): - VALID = "VALID" - INVALID = "INVALID" - NONE = "NONE" - - class Query(BaseModel): id: Any = Field(alias="_id") status: QueryStatus question_id: Any - response_id: Any + answer_id: Any question_date: str last_updated: str updated_by: Any organization_id: Any display_id: str | None slack_info: SlackInfo - custom_response: str | None + message: str | None + + +class SQLGenerationStatus(str, Enum): + VALID = "VALID" + INVALID = "INVALID" + NONE = "NONE" class SQLQueryResult(BaseModel): @@ -44,8 +44,8 @@ class SQLQueryResult(BaseModel): rows: list[dict] -class BaseEngineAnswer(BaseModel): - response: str | None +class BaseAnswer(BaseModel): + response: str | None # TODO: rename to message after engine refactor intermediate_steps: list[str] | None sql_query: str | None sql_query_result: SQLQueryResult | None @@ -57,6 +57,6 @@ class BaseEngineAnswer(BaseModel): error_message: str | None -class EngineAnswer(BaseEngineAnswer): - id: Any | None = Field(alias="_id") +class Answer(BaseAnswer): + id: Any = Field(alias="_id") question_id: Any diff --git a/apps/ai/server/modules/query/models/requests.py b/apps/ai/server/modules/query/models/requests.py index 64ba2e12..7178b5c4 100644 --- a/apps/ai/server/modules/query/models/requests.py +++ b/apps/ai/server/modules/query/models/requests.py @@ -12,10 +12,9 @@ class QuestionRequest(BaseModel): class QueryUpdateRequest(BaseModel): - sql_query: str query_status: QueryStatus | None - custom_response: str | None + message: str | None -class QueryExecutionRequest(BaseModel): +class SQLAnswerRequest(BaseModel): sql_query: str diff --git a/apps/ai/server/modules/query/models/responses.py b/apps/ai/server/modules/query/models/responses.py index c8c14241..12603ee2 100644 --- a/apps/ai/server/modules/query/models/responses.py +++ b/apps/ai/server/modules/query/models/responses.py @@ -1,18 +1,9 @@ from pydantic import BaseModel -from modules.query.models.entities import ( - BaseEngineAnswer, - QueryStatus, - SQLQueryResult, -) +from modules.query.models.entities import BaseAnswer, QueryStatus, SQLQueryResult from modules.user.models.responses import UserResponse -class EngineAnswerResponse(BaseEngineAnswer): - id: str | None - question_id: str - - class QuerySlackResponse(BaseModel): id: str display_id: str @@ -26,7 +17,7 @@ class QueryListResponse(BaseModel): id: str username: str question: str - question_date: str + question_date: str | None response: str | None status: QueryStatus | None evaluation_score: float | None @@ -34,9 +25,20 @@ class QueryListResponse(BaseModel): class QueryResponse(QueryListResponse): + question_id: str + answer_id: str sql_query_result: SQLQueryResult | None sql_query: str ai_process: list[str] = [] last_updated: str | None updated_by: UserResponse | None sql_error_message: str | None + + +class AnswerResponse(BaseAnswer): + id: str + question_id: str + + +class MessageResponse(BaseModel): + message: str diff --git a/apps/ai/server/modules/query/repository.py b/apps/ai/server/modules/query/repository.py index e25a476b..d9c11b8d 100644 --- a/apps/ai/server/modules/query/repository.py +++ b/apps/ai/server/modules/query/repository.py @@ -2,7 +2,7 @@ from config import QUERY_RESPONSE_COL, QUERY_RESPONSE_REF_COL, QUESTION_COL from database.mongo import DESCENDING, MongoDB -from modules.query.models.entities import EngineAnswer, Query, Question +from modules.query.models.entities import Answer, Query, Question from utils.misc import get_next_display_id @@ -16,20 +16,20 @@ def get_questions(self, question_ids: list[str]) -> list[Question]: questions = MongoDB.find_by_object_ids(QUESTION_COL, object_ids) return [Question(**question) for question in questions] - def get_answer(self, response_id: str) -> EngineAnswer: - answer = MongoDB.find_by_object_id(QUERY_RESPONSE_COL, ObjectId(response_id)) - return EngineAnswer(**answer) if answer else None + def get_answer(self, answer_id: str) -> Answer: + answer = MongoDB.find_by_object_id(QUERY_RESPONSE_COL, ObjectId(answer_id)) + return Answer(**answer) if answer else None - def get_answers(self, response_ids: list[str]) -> list[EngineAnswer]: + def get_answers(self, response_ids: list[str]) -> list[Answer]: object_ids = [ObjectId(id) for id in response_ids] answers = MongoDB.find(QUERY_RESPONSE_COL, {"_id": {"$in": object_ids}}) - return [EngineAnswer(**qr) for qr in answers] + return [Answer(**qr) for qr in answers] - def get_answers_by_question_id(self, question_id: str) -> list[EngineAnswer]: + def get_answers_by_question_id(self, question_id: str) -> list[Answer]: answers = MongoDB.find( QUERY_RESPONSE_COL, {"question_id": ObjectId(question_id)} ) - return [EngineAnswer(**qr) for qr in answers] + return [Answer(**qr) for qr in answers] def get_query(self, query_id: str) -> Query: query = MongoDB.find_one(QUERY_RESPONSE_REF_COL, {"_id": ObjectId(query_id)}) @@ -46,11 +46,11 @@ def get_queries( ) return [Query(**query) for query in queries] - def get_question_answers(self, question_id: str) -> list[EngineAnswer]: + def get_question_answers(self, question_id: str) -> list[Answer]: answers = MongoDB.find( QUERY_RESPONSE_COL, {"question_id": ObjectId(question_id)} ) - return [EngineAnswer(**answer) for answer in answers] + return [Answer(**answer) for answer in answers] def get_query_by_question_id(self, question_id: str) -> Query: query = MongoDB.find_one( diff --git a/apps/ai/server/modules/query/service.py b/apps/ai/server/modules/query/service.py index fbf8245a..d0c7052c 100644 --- a/apps/ai/server/modules/query/service.py +++ b/apps/ai/server/modules/query/service.py @@ -11,20 +11,20 @@ from modules.golden_sql.service import GoldenSQLService from modules.organization.models.responses import OrganizationResponse from modules.query.models.entities import ( - BaseEngineAnswer, - EngineAnswer, + Answer, Query, QueryStatus, Question, SQLGenerationStatus, ) from modules.query.models.requests import ( - QueryExecutionRequest, QueryUpdateRequest, QuestionRequest, + SQLAnswerRequest, ) from modules.query.models.responses import ( - EngineAnswerResponse, + AnswerResponse, + MessageResponse, QueryListResponse, QueryResponse, QuerySlackResponse, @@ -62,22 +62,22 @@ async def answer_question( # ask question to k2 engine async with httpx.AsyncClient() as client: response = await client.post( - settings.k2_core_url + "/questions", + settings.engine_url + "/questions", json={ "question": question_string, "db_connection_id": organization.db_connection_id, }, - timeout=settings.default_k2_core_timeout, + timeout=settings.default_engine_timeout, ) response_json = response.json() if not response_json["question_id"]: raise_for_status(response.status_code, response.text) - answer = EngineAnswerResponse(**response_json) + answer = AnswerResponse(**response_json) query = Query( question_id=ObjectId(answer.question_id), - response_id=ObjectId(answer.id) if answer.id else None, + answer_id=ObjectId(answer.id) if answer.id else None, question_date=current_utc_time, last_updated=current_utc_time, organization_id=ObjectId(organization.id), @@ -93,7 +93,7 @@ async def answer_question( else QueryStatus.SQL_ERROR, ) - query_id = self.repo.add_query(query.dict(exclude={"id"})) + query_id = self.repo.add_query(query.dict(exclude_unset=True)) self.analytics.track( question_request.slack_user_id, @@ -102,7 +102,7 @@ async def answer_question( "query_id": query_id, "display_id": query.display_id, "question_id": str(query.question_id), - "response_id": str(query.response_id), + "answer_id": str(query.answer_id), "organization_id": organization.id, "organization_name": organization.name, "database_name": self.db_connection_service.get_db_connection( @@ -144,7 +144,7 @@ async def answer_question( organization.db_connection_id ).alias, "display_id": query.display_id, - "status": query.status.value, + "status": query.status, "confidence_score": answer.confidence_score, "asker": username, }, @@ -171,9 +171,7 @@ async def answer_question( def get_query(self, query_id: str): query = self.repo.get_query(query_id) question = self.repo.get_question(str(query.question_id)) - answer = ( - self.repo.get_answer(str(query.response_id)) if query.response_id else None - ) + answer = self.repo.get_answer(str(query.answer_id)) if query.answer_id else None if query: return self._get_mapped_query_response(query, question, answer) @@ -197,8 +195,8 @@ def get_queries( id=str(query.id), username=query.slack_info.username or "unknown", question=question.question, - response=query.custom_response - or (answer.response if query.response_id else ""), + response=query.message + or (answer.response if query.answer_id else ""), status=query.status, question_date=query.question_date, evaluation_score=self._convert_confidence_score( @@ -217,7 +215,7 @@ def get_queries( [ result for query in queries - if (result := str(query.response_id) if query.response_id else None) + if (result := str(query.answer_id) if query.answer_id else None) is not None ] ) @@ -231,7 +229,7 @@ def get_queries( if question.id not in question_dict: question_dict[question.id] = question.question - query_response_dict: Dict[ObjectId, EngineAnswer | None] = {} + query_response_dict: Dict[ObjectId, Answer | None] = {} for answer in query_responses: query_response_dict[answer.id] = answer @@ -241,18 +239,18 @@ def get_queries( id=str(query.id), username=query.slack_info.username or "unknown", question=question_dict[query.question_id], - response=query.custom_response + response=query.message or ( - query_response_dict[query.response_id].response - if query.response_id + query_response_dict[query.answer_id].response + if query.answer_id else "" ), question_date=query.question_date, status=query.status, evaluation_score=self._convert_confidence_score( - query_response_dict[query.response_id].confidence_score + query_response_dict[query.answer_id].confidence_score ) - if query.response_id + if query.answer_id else 0, display_id=query.display_id, ) @@ -261,109 +259,77 @@ def get_queries( return [] - async def patch_response( + async def update_query( self, query_id: str, query_request: QueryUpdateRequest, - organization: OrganizationResponse, user: UserResponse, + organization: OrganizationResponse, ) -> QueryResponse: query = self.repo.get_query(query_id) - prev_sql_query = ( - self.repo.get_answer(str(query.response_id)).sql_query - if query.response_id - else None - ) - - if query_request.query_status != QueryStatus.REJECTED: - async with httpx.AsyncClient() as client: - response = await client.post( - settings.k2_core_url + "/responses", - json={ - "question_id": str(query.question_id), - "sql_query": query_request.sql_query, - }, - timeout=settings.default_k2_core_timeout, - ) - raise_for_status(response.status_code, response.text) - - new_query_response = EngineAnswerResponse(**response.json()) - else: - new_query_response = None - question = self.repo.get_question(query.question_id) + answer = self.repo.get_answer(str(query.answer_id)) if query.answer_id else None current_utc_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") - updated_query = { - "response_id": ObjectId(new_query_response.id) - if new_query_response - else None, + updated_request = { "last_updated": current_utc_time, "updated_by": ObjectId(user.id), - "status": query_request.query_status.value, - "custom_response": query_request.custom_response, + "message": query_request.message, } - self.repo.update_query(str(query.id), updated_query) + if query_request.query_status: + updated_request["status"] = query_request.query_status + + self.repo.update_query(str(query.id), updated_request) updated_query = self.repo.get_query(str(query.id)) # verified - if query_request.query_status == QueryStatus.VERIFIED: - golden_sql = GoldenSQLRequest( - question=question.question, - sql_query=query_request.sql_query, - db_connection_id=organization.db_connection_id, - ) - await self.golden_sql_service.add_verified_query_golden_sql( - golden_sql, - organization.id, - updated_query.id, - ) - - SlackWebClient( - organization.slack_installation.bot.token - ).send_verified_query_message( - updated_query, - new_query_response, - question.question, - ) + if query_request.query_status: + if query_request.query_status == QueryStatus.VERIFIED: + golden_sql = GoldenSQLRequest( + question=question.question, + sql_query=answer.sql_query, + db_connection_id=organization.db_connection_id, + ) + await self.golden_sql_service.add_verified_query_golden_sql( + golden_sql, + organization.id, + updated_query.id, + ) - # logic to track 1st time correct response generated by engine - all_answers = self.repo.get_answers_by_question_id( - str(updated_query.question_id) - ) - if all(answer.sql_query == prev_sql_query for answer in all_answers): - self.analytics.track( - user.email, - "verified_query_correct_on_first_try", - { - "query_id": query.id, - "question_id": str(query.question_id), - "response_id": str(query.response_id) - if query.response_id - else None, - "database_name": self.db_connection_service.get_db_connection( - organization.db_connection_id - ).alias, - "display_id": query.display_id, - "status": query.status.value, - "confidence_score": new_query_response.confidence_score - if new_query_response - else None, - }, + # logic to track 1st time correct response generated by engine + all_answers = self.repo.get_answers_by_question_id( + str(updated_query.question_id) ) + if all(ans.sql_query == answer.sql_query for ans in all_answers): + self.analytics.track( + user.email, + "verified_query_correct_on_first_try", + { + "query_id": query_id, + "question_id": str(updated_query.question_id), + "answer_id": str(updated_query.answer_id) + if updated_query.answer_id + else None, + "organization_id": organization.id, + "display_id": updated_query.display_id, + "status": query_request.query_status, + "confidence_score": answer.confidence_score + if answer + else None, + "database_name": self.db_connection_service.get_db_connection( + organization.db_connection_id + ).alias, + }, + ) - # rejected or not verified - else: - golden_sql_ref = self.golden_sql_service.get_verified_golden_sql_ref( - updated_query.id - ) - if golden_sql_ref: - await self.golden_sql_service.delete_golden_sql( - str(golden_sql_ref.golden_sql_id) + # rejected or not verified + else: + golden_sql_ref = self.golden_sql_service.get_verified_golden_sql_ref( + updated_query.id ) - if query_request.query_status == QueryStatus.REJECTED: - SlackWebClient( - organization.slack_installation.bot.token, - ).send_rejected_query_message(updated_query) + if golden_sql_ref: + await self.golden_sql_service.delete_golden_sql( + str(golden_sql_ref.golden_sql_id), query_request.query_status + ) self.analytics.track( user.email, @@ -371,80 +337,111 @@ async def patch_response( { "query_id": query_id, "question_id": str(updated_query.question_id), - "response_id": str(updated_query.response_id) - if updated_query.response_id + "answer_id": str(updated_query.answer_id) + if updated_query.answer_id else None, "database_name": self.db_connection_service.get_db_connection( organization.db_connection_id ).alias, "display_id": updated_query.display_id, - "status": query_request.query_status.value, - "confidence_score": new_query_response.confidence_score - if new_query_response - else None, - "asker": query.slack_info.username, + "status": query_request.query_status, + "confidence_score": answer.confidence_score if answer else None, }, ) - return self._get_mapped_query_response( - updated_query, question, new_query_response - ) + return self._get_mapped_query_response(updated_query, question, answer) - async def run_response( - self, query_id: str, query_request: QueryExecutionRequest, user: UserResponse - ): + async def generate_sql_answer( + self, query_id: str, sql_answer_request: SQLAnswerRequest, user: UserResponse + ) -> QueryResponse: query = self.repo.get_query(query_id) + question = self.repo.get_question(str(query.question_id)) async with httpx.AsyncClient() as client: response = await client.post( - settings.k2_core_url + "/responses", + settings.engine_url + "/responses", + params={"sql_response_only": True, "run_evaluator": False}, json={ "question_id": str(query.question_id), - "sql_query": query_request.sql_query, + "sql_query": sql_answer_request.sql_query, }, - timeout=settings.default_k2_core_timeout, + timeout=settings.default_engine_timeout, ) raise_for_status(response.status_code, response.text) - query.custom_response = None - question = self.repo.get_question(query.question_id) - new_query_response = EngineAnswerResponse(**response.json()) - + current_utc_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + answer = AnswerResponse(**response.json()) + updated_request = { + "last_updated": current_utc_time, + "updated_by": ObjectId(user.id), + "answer_id": ObjectId(answer.id), + } + self.repo.update_query(str(query.id), updated_request) self.analytics.track( user.email, "query_executed", { "query_id": query_id, - "question_id": new_query_response.id, - "response_id": new_query_response.id, + "question_id": question.id, + "answer_id": answer.id, + "sql_generation_status": answer.sql_generation_status, + "confidence_score": answer.confidence_score, "database_name": self.db_connection_service.get_db_connection( str(question.db_connection_id) ).alias, - "sql_generation_status": new_query_response.sql_generation_status.value, - "confidence_score": new_query_response.confidence_score, }, ) + return self._get_mapped_query_response(query, question, answer) + + async def generate_message(self, query_id: str) -> MessageResponse: + query = self.repo.get_query(query_id) + async with httpx.AsyncClient() as client: + response = await client.patch( + settings.engine_url + f"/responses/{str(query.answer_id)}", + timeout=settings.default_engine_timeout, + ) + raise_for_status(response.status_code, response.text) - return self._get_mapped_query_response(query, question, new_query_response) + answer = AnswerResponse(**response.json()) + self.repo.update_query(query_id, {"message": answer.response}) + return MessageResponse(message=answer.response) + + async def send_message(self, query_id: str, organization: OrganizationResponse): + query = self.repo.get_query(query_id) + question = self.repo.get_question(str(query.question_id)) + answer = self.repo.get_answer(str(query.answer_id)) if query.answer_id else None + + message = ( + f":wave: Hello, <@{query.slack_info.user_id}>! An Admin has reviewed {query.display_id}.\n\n" + + f"Question: {question.question}\n\n" + + f"Response: {query.message or answer.response}\n\n" + + f":memo: *Generated SQL Query*: \n ```{answer.sql_query}```" + ) + + SlackWebClient(organization.slack_installation.bot.token).send_message( + query.slack_info.channel_id, query.slack_info.thread_ts, message + ) def _get_mapped_query_response( self, query: Query, question: Question, - answer: BaseEngineAnswer = None, + answer: Answer = None, ) -> QueryResponse: if not answer: - answer = BaseEngineAnswer( - response_id=None, - question_id=query.question_id, - response=query.custom_response or "", + answer = Answer( + id=None, + question_id=question.id, + response=query.message or "", sql_query="", confidence_score=0, ) return QueryResponse( id=str(query.id), + question_id=str(question.id), + answer_id=str(answer.id), username=query.slack_info.username or "unknown", question=question.question, - response=query.custom_response or answer.response, + response=query.message or answer.response, sql_query=answer.sql_query, sql_query_result=answer.sql_query_result, ai_process=answer.intermediate_steps or ["process unknown"], @@ -463,7 +460,7 @@ def _get_mapped_query_response( def _convert_confidence_score(self, confidence_score: float) -> int: if not confidence_score: - return 0 + return None if confidence_score > CONFIDENCE_CAP: return 95 return int(confidence_score * 100) diff --git a/apps/ai/server/modules/table_description/service.py b/apps/ai/server/modules/table_description/service.py index a1facdeb..d4830941 100644 --- a/apps/ai/server/modules/table_description/service.py +++ b/apps/ai/server/modules/table_description/service.py @@ -23,9 +23,9 @@ async def get_table_descriptions( ) -> list[TableDescriptionResponse]: async with httpx.AsyncClient() as client: response = await client.get( - settings.k2_core_url + "/table-descriptions", + settings.engine_url + "/table-descriptions", params={"db_connection_id": db_connection_id, "table_name": table_name}, - timeout=settings.default_k2_core_timeout, + timeout=settings.default_engine_timeout, ) raise_for_status(response.status_code, response.text) @@ -46,8 +46,8 @@ async def get_table_description( ) -> TableDescriptionResponse: async with httpx.AsyncClient() as client: response = await client.get( - settings.k2_core_url + f"/table-descriptions/{table_description_id}", - timeout=settings.default_k2_core_timeout, + settings.engine_url + f"/table-descriptions/{table_description_id}", + timeout=settings.default_engine_timeout, ) raise_for_status(response.status_code, response.text) @@ -65,9 +65,9 @@ async def get_database_table_descriptions(self, db_connection_id: str): async with httpx.AsyncClient() as client: response = await client.get( - settings.k2_core_url + "/table-descriptions", + settings.engine_url + "/table-descriptions", params={"db_connection_id": db_connection_id, "table_name": ""}, - timeout=settings.default_k2_core_timeout, + timeout=settings.default_engine_timeout, ) raise_for_status(response.status_code, response.text) @@ -99,9 +99,9 @@ async def get_database_table_descriptions(self, db_connection_id: str): async def sync_table_descriptions_schemas(self, scan_request: ScanRequest) -> bool: async with httpx.AsyncClient() as client: response = await client.post( - settings.k2_core_url + "/table-descriptions/sync-schemas", + settings.engine_url + "/table-descriptions/sync-schemas", json=scan_request.dict(), - timeout=settings.default_k2_core_timeout, + timeout=settings.default_engine_timeout, ) raise_for_status(response.status_code, response.text) return response.json() @@ -113,7 +113,7 @@ async def update_table_description( ) -> TableDescriptionResponse: async with httpx.AsyncClient() as client: response = await client.patch( - settings.k2_core_url + f"/table-descriptions/{table_description_id}", + settings.engine_url + f"/table-descriptions/{table_description_id}", json=table_description_request.dict(exclude_unset=True), ) raise_for_status(response.status_code, response.text) @@ -122,7 +122,7 @@ async def update_table_description( async def delete_table_description(self, table_description_id: str): async with httpx.AsyncClient() as client: response = await client.delete( - settings.k2_core_url + f"/table-descriptions/{table_description_id}", + settings.engine_url + f"/table-descriptions/{table_description_id}", ) raise_for_status(response.status_code, response.text) return True diff --git a/apps/ai/server/tests/golden_sql/test_golden_sql_api.py b/apps/ai/server/tests/golden_sql/test_golden_sql_api.py index 5d89783c..e6dc0061 100644 --- a/apps/ai/server/tests/golden_sql/test_golden_sql_api.py +++ b/apps/ai/server/tests/golden_sql/test_golden_sql_api.py @@ -171,6 +171,7 @@ def test_add_golden_sql(self): get_golden_sql_ref=Mock(return_value=GoldenSQLRef(**test_ref_1)), delete_verified_golden_sql_ref=Mock(return_value=1), delete_golden_sql_ref=Mock(return_value=1), + update_query_status=Mock(return_value=None), ) def test_delete_golden_sql(self): response = client.delete( diff --git a/apps/ai/server/tests/query/test_query_api.py b/apps/ai/server/tests/query/test_query_api.py index f9421e06..850fba46 100644 --- a/apps/ai/server/tests/query/test_query_api.py +++ b/apps/ai/server/tests/query/test_query_api.py @@ -10,7 +10,7 @@ from modules.db_connection.models.responses import DBConnectionResponse from modules.organization.models.entities import SlackBot, SlackInstallation from modules.organization.models.responses import OrganizationResponse -from modules.query.models.entities import Query +from modules.query.models.entities import Query, Question from modules.user.models.responses import UserResponse client = TestClient(app) @@ -21,7 +21,7 @@ "utils.auth.Authorize", user=Mock( return_value=UserResponse( - id="123", + id="0123456789ab0123456789ab", email="test@gmail.com", username="test_user", organization_id="0123456789ab0123456789ab", @@ -84,11 +84,11 @@ class TestQueryAPI(TestCase): test_ref_1 = { "_id": ObjectId(b"doo-ree-miii"), - "response_id": test_0["_id"], + "answer_id": test_0["_id"], "question_id": test_question["_id"], "question_date": "2023-09-15 21:14:29", "status": "NOT_VERIFIED", - "custom_response": None, + "message": None, "last_updated": "2023-09-15 21:14:29", "updated_by": None, "organization_id": ObjectId(b"foo-bar-quux"), @@ -123,6 +123,8 @@ class TestQueryAPI(TestCase): "last_updated": test_ref_1["last_updated"], "updated_by": None, "sql_error_message": "test_error", + "question_id": str(test_question["_id"]), + "answer_id": str(test_0["_id"]), **test_list_response_1, } @@ -135,6 +137,10 @@ class TestQueryAPI(TestCase): "is_above_confidence_threshold": False, } + test_message_response_1 = { + "message": "test_response", + } + @patch( "httpx.AsyncClient.post", AsyncMock(return_value=Response(status_code=201, json=test_response_0)), @@ -218,29 +224,37 @@ def test_get_query(self): assert response.json() == self.test_response_1 @patch( - "modules.organization.service.OrganizationService.get_organization", - Mock(return_value={"id": "666f6f2d6261722d71757578"}), + "httpx.AsyncClient.post", + AsyncMock(return_value=Response(201, json=test_response_0)), ) - @patch( - "modules.query.service.QueryService.patch_response", - AsyncMock(return_value=test_response_1), + @patch.multiple( + "modules.query.repository.QueryRepository", + get_query=Mock(return_value=Query(**test_ref_1)), + get_question=Mock(return_value=Question(**test_question)), + update_query=Mock(return_value=None), ) - def test_patch_response(self): - response = client.patch( - self.url + "/666f6f2d6261722d71757578", + def test_generate_sql_answer(self): + response = client.post( + self.url + "/666f6f2d6261722d71757578/sql-answer", headers=self.test_header, - json={"sql_query": "test_query", "query_status": "VERIFIED"}, + json={"sql_query": "test_query"}, ) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == self.test_response_1 @patch( - "modules.query.service.QueryService.run_response", - AsyncMock(return_value=test_response_1), + "httpx.AsyncClient.patch", + AsyncMock(return_value=Response(200, json=test_response_0)), ) - def test_run_response(self): - response = client.post( - self.url + "/666f6f2d6261722d71757578/answer", + @patch.multiple( + "modules.query.repository.QueryRepository", + get_query=Mock(return_value=Query(**test_ref_1)), + update_query=Mock(return_value=None), + ) + def test_generate_message(self): + response = client.patch( + self.url + "/666f6f2d6261722d71757578/message", headers=self.test_header, - json={"sql_query": "test_query"}, ) assert response.status_code == status.HTTP_200_OK + assert response.json() == self.test_message_response_1 diff --git a/apps/ai/server/utils/slack.py b/apps/ai/server/utils/slack.py index 20d780fd..d95f142f 100644 --- a/apps/ai/server/utils/slack.py +++ b/apps/ai/server/utils/slack.py @@ -2,8 +2,7 @@ from slack_sdk import WebClient -from modules.query.models.entities import Query -from modules.query.models.responses import EngineAnswerResponse +from modules.query.models.entities import Answer, Query, Question class SlackWebClient: @@ -24,69 +23,69 @@ def send_message(self, channel_id: str, thread_ts: str, message: str): def send_verified_query_message( self, - query_ref: Query, - query_response: EngineAnswerResponse, - question: str, + query: Query, + question: Question, + answer: Answer, ): message_blocks = [ { "type": "section", "text": { "type": "mrkdwn", - "text": f":wave: Hello, <@{query_ref.slack_info.user_id}>! Your query {query_ref.display_id} has been verified.", + "text": f":wave: Hello, <@{query.slack_info.user_id}>! Your query {query.display_id} has been verified.", }, }, { "type": "section", "text": { "type": "mrkdwn", - "text": f"Question: {question}", + "text": f"Question: {question.question}", }, }, { "type": "section", "text": { "type": "mrkdwn", - "text": f"Response: {query_ref.custom_response or query_response.response}", + "text": f"Response: {query.message or answer.response}", }, }, { "type": "section", "text": { "type": "mrkdwn", - "text": f":memo: *Generated SQL Query*: \n ```{query_response.sql_query}```", + "text": f":memo: *Generated SQL Query*: \n ```{answer.sql_query}```", }, }, ] self.client.chat_postMessage( - channel=query_ref.slack_info.channel_id, - thread_ts=query_ref.slack_info.thread_ts, + channel=query.slack_info.channel_id, + thread_ts=query.slack_info.thread_ts, blocks=message_blocks, ) def send_rejected_query_message( self, - query_ref: Query, + query: Query, ): message_blocks = [ { "type": "section", "text": { "type": "mrkdwn", - "text": f":wave: Hello, <@{query_ref.slack_info.user_id}>. Your query {query_ref.display_id} could not be answered.", + "text": f":wave: Hello, <@{query.slack_info.user_id}>. Your query {query.display_id} could not be answered.", }, }, { "type": "section", "text": { "type": "mrkdwn", - "text": f"Reason: {query_ref.custom_response}", + "text": f"Reason: {query.message}", }, }, ] self.client.chat_postMessage( - channel=query_ref.slack_info.channel_id, - thread_ts=query_ref.slack_info.thread_ts, + channel=query.slack_info.channel_id, + thread_ts=query.slack_info.thread_ts, blocks=message_blocks, )