diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 405c7dcd..4b8dcc7d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,10 +1,13 @@ ## Description + ## Related Issue + ## Type of Change + - [ ] Bug fix (non-breaking change which fixes an issue) @@ -15,12 +18,15 @@ - [ ] Performance improvement ## How Has This Been Tested? + ## Screenshots (if applicable) + ## Checklist + - [ ] My code follows the project's style guidelines @@ -32,4 +38,5 @@ - [ ] New and existing unit tests pass locally with my changes ## Additional Notes - + + diff --git a/.vscode/settings.json b/.vscode/settings.json index 6c266075..a4341069 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,4 +8,7 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, + "cSpell.words": [ + "cambio" + ], } \ No newline at end of file diff --git a/README.md b/README.md index b96cdb1e..7ff0d613 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Cambio Website Source + For Dev setup, please refer to this [Notion Page](https://www.notion.so/goldpiggy/TS-JS-Dev-Setup-ada0f7cdf74c424c8767ed692150cc88?pvs=4). You may need to request access, and you will only receive access if appropriate. ## Quick setup + - checkout a dev branch - copy .env from 1password - `npm install` for dependency - `npm run dev` for local development and testing - open `http://localhost:3000` in your browser -- \ No newline at end of file +- diff --git a/app/actions/apiInterface.ts b/app/actions/apiInterface.ts index f2bfeb4d..1afaa2d7 100644 --- a/app/actions/apiInterface.ts +++ b/app/actions/apiInterface.ts @@ -51,6 +51,7 @@ export interface JobParams { vqaPageNumsFlag?: boolean; vqaTableOnlyFlag?: boolean; vqaChartOnlyFlag?: boolean; + vqaExtractInstruction?: Record; }; schemaInfo?: { dbSchema?: string[]; diff --git a/app/actions/runSyncExtractKeyValue.ts b/app/actions/runSyncExtractKeyValue.ts new file mode 100644 index 00000000..aa8918a6 --- /dev/null +++ b/app/actions/runSyncExtractKeyValue.ts @@ -0,0 +1,41 @@ +import axios from 'axios'; + +interface IParams { + token: string; + apiUrl: string; + base64String: string; + extractInstruction: Record; + fileType?: string; +} + +export const runSyncExtractKeyValue = async ({ + token, + apiUrl, + base64String, + extractInstruction, +}: IParams): Promise => { + const extractAPI = `${apiUrl}/extract_key_value`; + const params = { + file_content: base64String, + file_type: 'pdf', + extract_args: { + extract_instruction: extractInstruction, + }, + }; + + const config = { + headers: { + 'x-api-key': '-', + Authorization: token, + }, + }; + + const extractKeyValueResponse = await axios.post(extractAPI, params, config); + + if (extractKeyValueResponse.status !== 200) { + throw new Error('Failed to extract key value pairs'); + } + + const json = extractKeyValueResponse.data.json[0]; + return json; +}; diff --git a/app/actions/uploadFile.ts b/app/actions/uploadFile.ts index 3687fd43..5d2d6030 100644 --- a/app/actions/uploadFile.ts +++ b/app/actions/uploadFile.ts @@ -18,6 +18,7 @@ interface IParams { vqaPageNumsFlag?: boolean; vqaTableOnlyFlag?: boolean; vqaChartOnlyFlag?: boolean; + extractInstruction?: Record; }; addFilesFormData: (data: PresignedResponse) => void; } @@ -60,6 +61,7 @@ export const uploadFile = async ({ vqa_page_nums_flag: extractArgs.vqaPageNumsFlag, vqa_table_only_flag: extractArgs.vqaTableOnlyFlag, vqa_table_only_caption_flag: extractArgs.vqaChartOnlyFlag, + extract_instruction: extractArgs.extractInstruction, }; const requestBody = { diff --git a/app/components/Button.tsx b/app/components/Button.tsx index a4d39d2e..fac9c59d 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -35,6 +35,7 @@ const Button = ({ disabled:opacity-70 disabled:cursor-not-allowed rounded-xl + whitespace-nowrap hover:bg-neutral-200 hover:text-cambio-gray transition diff --git a/app/components/playground/ActionContainer.tsx b/app/components/playground/ActionContainer.tsx index 809c3d94..00086255 100644 --- a/app/components/playground/ActionContainer.tsx +++ b/app/components/playground/ActionContainer.tsx @@ -1,11 +1,12 @@ import usePlaygroundStore from '@/app/hooks/usePlaygroundStore'; import LoginComponent from '../auth/Login'; import PlaygroundTab from './PlaygroundTab'; -import ExtractContainer from './ExtractContainer'; import { useEffect, useState } from 'react'; import { PlaygroundFile, PlaygroundTabs } from '@/app/types/PlaygroundTypes'; import UploadButton from './UploadButton'; import MapContainer from './table/MapContainer'; +import ExtractContainer from './ExtractContainer'; +import ExtractKeyValuePairContainer from './ExtractKeyValuePairContainer'; const ActionContainer = () => { const { loggedIn, selectedFileIndex, files } = usePlaygroundStore(); @@ -19,7 +20,7 @@ const ActionContainer = () => { return (
-
+
{Object.values(PlaygroundTabs).map((tab) => ( ))} @@ -33,11 +34,12 @@ const ActionContainer = () => {
) : ( -
+
{(selectedFile?.activeTab === PlaygroundTabs.PLAIN_TEXT || selectedFileIndex === null) && ( )} {selectedFile?.activeTab === PlaygroundTabs.TABLE && } + {selectedFile?.activeTab === PlaygroundTabs.KEY_VALUE_PAIR && }
) ) : ( diff --git a/app/components/playground/ExtractKeyValuePairContainer.tsx b/app/components/playground/ExtractKeyValuePairContainer.tsx new file mode 100644 index 00000000..7841644d --- /dev/null +++ b/app/components/playground/ExtractKeyValuePairContainer.tsx @@ -0,0 +1,223 @@ +import { toast } from 'react-hot-toast'; +import { useEffect, useMemo, useState } from 'react'; +import { useProductionContext } from './ProductionContext'; +import { ArrowLeft, Download } from '@phosphor-icons/react'; +import { uploadFile } from '@/app/actions/uploadFile'; +import { JobParams } from '@/app/actions/apiInterface'; +import { runAsyncRequestJob } from '@/app/actions/runAsyncRequestJob'; +import { PlaygroundFile, ExtractState, ProcessType, JobType } from '@/app/types/PlaygroundTypes'; +import { runAsyncRequestJob as runPreprodAsyncRequestJob } from '@/app/actions/preprod/runAsyncRequestJob'; +import Button from '../Button'; +import CodeBlock from '../CodeBlock'; +import DocumentViewer from '../DocumentViewer'; +import KeyValueInputs from './KeyValueInputs'; +import usePlaygroundStore from '@/app/hooks/usePlaygroundStore'; +import ExtractKeyValuePairTutorial from '../tutorials/ExtractKeyValuePairTutorial'; + +const downloadExtractedData = (formattedData: string, file?: PlaygroundFile['file']) => { + if (!formattedData) return; + + const fileName = file instanceof File ? file.name : 'extracted_data'; + const blob = new Blob([formattedData], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const baseFileName = fileName.replace(/\.[^/.]+$/, ''); + a.download = `${baseFileName}_extracted.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +}; + +const ExtractKeyValuePairContainer = () => { + const [hideResult, setHideResult] = useState(false); + const { apiURL, isProduction } = useProductionContext(); + const { selectedFileIndex, files, updateFileAtIndex, token, userId, clientId, addFilesFormData } = + usePlaygroundStore(); + + const selectedFile = useMemo(() => { + if (selectedFileIndex !== null && files.length > 0) { + return files[selectedFileIndex]; + } + }, [selectedFileIndex, files]); + + useEffect(() => { + if (!selectedFile) return; + + if ( + selectedFile.extractKeyValueState === ExtractState.EXTRACTING || + selectedFile.extractKeyValueState === ExtractState.UPLOADING + ) { + toast.loading('Extracting data...', { id: 'key-value-extracting-toast' }); + } else { + toast.dismiss('key-value-extracting-toast'); + } + }, [selectedFile?.extractKeyValueState]); + + const handleSuccess = async (response: any) => { + if (!response.data) { + toast.error( + `${selectedFile?.file instanceof File ? selectedFile.file.name : 'File'}: Received undefined result. Please try again.` + ); + updateFileAtIndex(selectedFileIndex, 'extractKeyValueState', ExtractState.READY); + return; + } + + const formattedResult = JSON.stringify(response.data.json[0], null, 2); + + updateFileAtIndex(selectedFileIndex, 'extractKeyValueResult', formattedResult); + updateFileAtIndex(selectedFileIndex, 'extractKeyValueState', ExtractState.DONE_EXTRACTING); + + toast.success('Extraction complete!'); + }; + + const handleError = (error: any) => { + if (error.response) { + if (error.response.status === 429) { + toast.error('Extract limit reached.'); + updateFileAtIndex(selectedFileIndex, 'extractKeyValueState', ExtractState.LIMIT_REACHED); + } else { + toast.error('Extraction failed. Please try again.'); + updateFileAtIndex(selectedFileIndex, 'extractKeyValueState', ExtractState.READY); + } + } else { + toast.error('Error during extraction. Please try again.'); + updateFileAtIndex(selectedFileIndex, 'extractKeyValueState', ExtractState.READY); + } + console.error(error); + }; + + const handleTimeout = () => { + updateFileAtIndex(selectedFileIndex, 'extractKeyValueState', ExtractState.READY); + toast.error('Extract request timed out. Please try again.'); + }; + + const onSubmit = async (extractInstruction: Record) => { + if (!selectedFile?.file) { + toast.error('Please select a file first'); + return; + } + + if (selectedFileIndex === null) { + toast.error('No file selected'); + return; + } + + try { + updateFileAtIndex(selectedFileIndex, 'extractKeyValueState', ExtractState.UPLOADING); + const file = selectedFile.file; + + const jobParams: JobParams = { + vqaProcessorArgs: { + vqaExtractInstruction: extractInstruction, + }, + }; + + // Upload file and get presigned url and metadata + const uploadResult = await uploadFile({ + api_url: apiURL, + userId, + token, + file: file as File, + process_type: ProcessType.EXTRACT_KEY_VALUE, + extractArgs: { + extractInstruction, + }, + addFilesFormData, + }); + + if (uploadResult instanceof Error) { + toast.error('Error uploading file. Please try again.'); + updateFileAtIndex(selectedFileIndex, 'extractKeyValueState', ExtractState.READY); + return; + } + + const fileData = uploadResult.data; + updateFileAtIndex(selectedFileIndex, 'extractKeyValueState', ExtractState.EXTRACTING); + + // Common job parameters + const jobConfig = { + apiURL, + jobType: JobType.KEY_VALUE_EXTRACTION, + userId, + clientId, + fileId: fileData.fileId, + fileData, + selectedFile, + token, + sourceType: 's3', + jobParams, + selectedFileIndex, + filename: file instanceof File ? file.name : 'file', + handleError, + handleSuccess, + handleTimeout, + updateFileAtIndex, + } as const; + + // Run the async job based on environment + const runJob = isProduction ? runAsyncRequestJob : runPreprodAsyncRequestJob; + await runJob(jobConfig); + } catch (error) { + toast.error('Extraction failed. Please try again.'); + console.error(error); + updateFileAtIndex(selectedFileIndex, 'extractKeyValueState', ExtractState.READY); + } + }; + + const fileUrl = useMemo(() => { + if (!selectedFile?.file) return ''; + if (typeof selectedFile.file === 'string') return selectedFile.file; + return URL.createObjectURL(selectedFile.file); + }, [selectedFile?.file]); + + return ( +
+
+ + {fileUrl && (hideResult || !selectedFile?.extractKeyValueResult) && ( +
+ + {selectedFile?.extractKeyValueResult && ( +
+
+ )} +
+ )} + {!hideResult && selectedFile?.extractKeyValueResult && ( +
+ +
+
+
+ )} +
+
+
+
+ +
+
+
+
+ ); +}; + +export default ExtractKeyValuePairContainer; diff --git a/app/components/playground/KeyValueInputs.tsx b/app/components/playground/KeyValueInputs.tsx new file mode 100644 index 00000000..688d2f2c --- /dev/null +++ b/app/components/playground/KeyValueInputs.tsx @@ -0,0 +1,323 @@ +import { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { FieldErrors, FieldValues, UseFormRegister } from 'react-hook-form'; +import { BiPlus, BiMinus, BiCaretRight } from 'react-icons/bi'; +import toast from 'react-hot-toast'; +import { v4 as uuidv4 } from 'uuid'; +import Button from '../Button'; +import usePlaygroundStore from '@/app/hooks/usePlaygroundStore'; + +interface InputProps { + id: string; + errors: FieldErrors; + register: UseFormRegister; + onAdd?: () => void; + onRemove?: () => void; + canRemove?: boolean; + onInputChange: () => void; +} + +interface KeyValueInputsProps { + onSubmit: (extractInstructions: Record) => void; + isLoading?: boolean; +} + +const AddButton = ({ onClick }: { onClick?: () => void }) => { + return ( + + ); +}; + +const RemoveButton = ({ onClick }: { onClick?: () => void }) => { + return ( + + ); +}; + +const ExpandButton = ({ active, onClick }: { active: boolean; onClick: () => void }) => { + return ( + + ); +}; + +const Input = ({ id, errors, register, onAdd, onRemove, canRemove = true, onInputChange }: InputProps) => { + const [descriptionExpanded, setDescriptionExpanded] = useState(false); + return ( +
+
+ + {canRemove && } +
+
+ { + setDescriptionExpanded(!descriptionExpanded); + }} + active={descriptionExpanded} + /> +
+
+
+ setTimeout(onInputChange, 0), + })} + placeholder="Key Name" + type="text" + aria-label="Key Name Input" + className={` + w-full + p-1 + font-light + bg-white + border-2 + rounded-md + outline-none + transition-colors + ${errors[`${id}-key`] ? 'border-rose-500 bg-rose-50' : 'border-neutral-300'} + ${errors[`${id}-key`] ? 'focus:border-rose-500' : 'focus:border-black'} + `} + /> + {errors[`${id}-key`] && ( + + {errors[`${id}-key`]?.message?.toString() || 'Key name is required'} + + )} +
+