diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index fbeb013ed..4470ae100 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -3487,6 +3487,289 @@ + + ai + + + accept_and_continue + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + confidence + + + breakdown + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + match + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + missing_data + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + ocr + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + overall + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + disclaimer_title + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + multipage + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + processing + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + scan + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + scancomplete + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + scanfailed + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + scanstarted + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + bill_lines false @@ -21167,6 +21450,27 @@ + + beta + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + cancel false diff --git a/client/src/components/bill-enter-ai-scan/bill-enter-ai-scan.component.jsx b/client/src/components/bill-enter-ai-scan/bill-enter-ai-scan.component.jsx index 23659d21c..02745ecdc 100644 --- a/client/src/components/bill-enter-ai-scan/bill-enter-ai-scan.component.jsx +++ b/client/src/components/bill-enter-ai-scan/bill-enter-ai-scan.component.jsx @@ -7,7 +7,7 @@ import { createStructuredSelector } from "reselect"; import { useNotification } from "../../contexts/Notifications/notificationContext"; import { selectBillEnterModal } from "../../redux/modals/modals.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors"; -import { useApolloClient } from "@apollo/client/react"; +import { useTranslation } from "react-i18next"; const mapStateToProps = createStructuredSelector({ billEnterModal: selectBillEnterModal, @@ -26,23 +26,20 @@ function BillEnterAiScan({ setIsAiScan }) { const notification = useNotification(); + const { t } = useTranslation(); const [showBetaModal, setShowBetaModal] = useState(false); const BETA_ACCEPTANCE_KEY = "ai_scan_beta_acceptance"; - const client = useApolloClient(); const handleBetaAcceptance = () => { localStorage.setItem(BETA_ACCEPTANCE_KEY, "true"); setShowBetaModal(false); - // Trigger the file input after acceptance fileInputRef.current?.click(); }; const checkBetaAcceptance = () => { const hasAccepted = localStorage.getItem(BETA_ACCEPTANCE_KEY); if (hasAccepted) { - // User has already accepted, proceed with file selection fileInputRef.current?.click(); } else { - // Show beta modal setShowBetaModal(true); } }; @@ -65,8 +62,7 @@ function BillEnterAiScan({ form.setFieldsValue(data.data.billForm); await form.validateFields(["billlines"], { recursive: true }); notification.success({ - title: "AI Scan Complete", - message: "Invoice data has been extracted successfully" + title: t(".bills.labels.ai.scancomplete") }); } } else if (data.status === "FAILED") { @@ -78,8 +74,8 @@ function BillEnterAiScan({ setScanLoading(false); notification.error({ - title: "AI Scan Failed", - message: data.error || "Failed to process the invoice" + title: t("bills.labels.ai.scanfailed"), + message: data.error || "" }); } // If status is IN_PROGRESS, continue polling @@ -92,7 +88,7 @@ function BillEnterAiScan({ setScanLoading(false); notification.error({ - title: "AI Scan Error", + title: t("bills.labels.ai.scanfailed"), message: error.response?.data?.message || error.message || "Failed to check scan status" }); } @@ -123,8 +119,8 @@ function BillEnterAiScan({ if (status === 202) { // Multipage PDF - start polling notification.info({ - title: "Processing Invoice", - message: "This is a multipage document. Processing may take a few moments..." + title: t("bills.labels.ai.scanstarted"), + message: t("bills.labels.ai.multipage") }); //Workaround needed to bypass react-compiler error about manipulating refs in child components. Refactor may be needed in the future to clean this up. @@ -144,14 +140,13 @@ function BillEnterAiScan({ await form.validateFields(["billlines"], { recursive: true }); notification.success({ - title: "AI Scan Complete", - message: "Invoice data has been extracted successfully" + title: t("bills.labels.ai.scancomplete") }); } } catch (error) { setScanLoading(false); notification.error({ - title: "AI Scan Failed", + title: t("bills.labels.ai.scanfailed"), message: error.response?.data?.message || error.message || "Failed to process invoice" }); } @@ -162,20 +157,21 @@ function BillEnterAiScan({ /> setShowBetaModal(false)} - okText="Accept and Continue" - cancelText="Cancel" + okText={t("bills.labels.ai.accept_and_continue")} + cancelText={t("general.labels.cancel")} > - AI Usage Disclaimer - + { + //This is explicitly not translated. + } This AI scanning feature is currently in beta. While it can accelerate data entry, you{" "} must carefully review all extracted results for accuracy. diff --git a/client/src/components/bill-form/bill-form.lines.confidence.component.jsx b/client/src/components/bill-form/bill-form.lines.confidence.component.jsx index 8854cc212..23ce5b702 100644 --- a/client/src/components/bill-form/bill-form.lines.confidence.component.jsx +++ b/client/src/components/bill-form/bill-form.lines.confidence.component.jsx @@ -36,11 +36,9 @@ const ConfidenceDisplay = ({ rowValue: { confidence, actual_price, actual_cost } -
- {t("billlines.confidence.breakdown", { defaultValue: "Confidence Breakdown" })} -
+
{t("bills.labels.ai.confidence.breakdown")}
- {t("billlines.confidence.overall", { defaultValue: "Overall" })}: {total.toFixed(1)}% + {t("bills.labels.ai.confidence.overall")}: {total.toFixed(1)}%
- {t("billlines.confidence.ocr", { defaultValue: "OCR" })}: {ocr.toFixed(1)}% + {t("bills.labels.ai.confidence.ocr")}: {ocr.toFixed(1)}%
- {t("billlines.confidence.match", { defaultValue: "Job Match" })}: {jobMatch.toFixed(1)}% + {t("bills.labels.ai.confidence.match")}: {jobMatch.toFixed(1)}% {!parsed_actual_cost || !parsed_actual_price || parsed_actual_cost === 0 || parsed_actual_price === 0 ? ( - {t("billlines.confidence.missing_data", { defaultValue: "Missing Data" })} + {t("bills.labels.ai.confidence.missing_data")} ) : null} diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 06a585263..89263a568 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -218,6 +218,23 @@ }, "labels": { "actions": "Actions", + "ai": { + "accept_and_continue": "Accept and Continue", + "confidence": { + "breakdown": "Confidence Breakdown", + "match": "Jobline Match", + "missing_data": "Missing Data", + "ocr": "Optical Character Recognition", + "overall": "Overall" + }, + "disclaimer_title": "AI Scan Beta Disclaimer", + "multipage": "The is a multi-page document. Processing will take a few moments.", + "processing": "Analyzing Bill", + "scan": "AI Bill Scanner", + "scancomplete": "AI Scan Complete", + "scanfailed": "AI Scan Failed", + "scanstarted": "AI Scan Started" + }, "bill_lines": "Bill Lines", "bill_total": "Bill Total Amount", "billcmtotal": "Credit Memos", @@ -1296,6 +1313,7 @@ "apply": "Apply", "areyousure": "Are you sure?", "barcode": "Barcode", + "beta": "BETA", "cancel": "Are you sure you want to cancel? Your changes will not be saved.", "changelog": "Change Log", "clear": "Clear", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index a7a8113a9..27f99dd56 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -218,6 +218,23 @@ }, "labels": { "actions": "", + "ai": { + "accept_and_continue": "", + "confidence": { + "breakdown": "", + "match": "", + "missing_data": "", + "ocr": "", + "overall": "" + }, + "disclaimer_title": "", + "multipage": "", + "processing": "", + "scan": "", + "scancomplete": "", + "scanfailed": "", + "scanstarted": "" + }, "bill_lines": "", "bill_total": "", "billcmtotal": "", @@ -1296,6 +1313,7 @@ "apply": "", "areyousure": "", "barcode": "código de barras", + "beta": "", "cancel": "", "changelog": "", "clear": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index e756089c7..f781667be 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -218,6 +218,23 @@ }, "labels": { "actions": "", + "ai": { + "accept_and_continue": "", + "confidence": { + "breakdown": "", + "match": "", + "missing_data": "", + "ocr": "", + "overall": "" + }, + "disclaimer_title": "", + "multipage": "", + "processing": "", + "scan": "", + "scancomplete": "", + "scanfailed": "", + "scanstarted": "" + }, "bill_lines": "", "bill_total": "", "billcmtotal": "", @@ -1296,6 +1313,7 @@ "apply": "", "areyousure": "", "barcode": "code à barre", + "beta": "", "cancel": "", "changelog": "", "clear": "", diff --git a/server/ai/bill-ocr/bill-ocr.js b/server/ai/bill-ocr/bill-ocr.js index e9752daa7..b04898171 100644 --- a/server/ai/bill-ocr/bill-ocr.js +++ b/server/ai/bill-ocr/bill-ocr.js @@ -5,7 +5,7 @@ const { v4: uuidv4 } = require('uuid'); const { getTextractJobKey, setTextractJob, getTextractJob, getFileType, getPdfPageCount, hasActiveJobs } = require("./bill-ocr-helpers"); const { extractInvoiceData, processScanData } = require("./bill-ocr-normalize"); const { generateBillFormData } = require("./bill-ocr-generator"); - +const logger = require("../../utils/logger"); // Initialize AWS clients const awsConfig = { region: process.env.AWS_AI_REGION || "ca-central-1", @@ -53,24 +53,27 @@ async function jobExists(textractJobId) { return false; } -async function handleBillOcr(request, response) { +async function handleBillOcr(req, res) { // Check if file was uploaded - if (!request.file) { - response.status(400).send({ error: 'No file uploaded.' }); + if (!req.file) { + res.status(400).send({ error: 'No file uploaded.' }); return; } - // The uploaded file is available in request.file - const uploadedFile = request.file; - const { jobid, bodyshopid, partsorderid } = request.body; + // The uploaded file is available in request file + const uploadedFile = req.file; + const { jobid, bodyshopid, partsorderid } = req.body; + logger.log("bill-ocr-start", "DEBUG", req.user.email, jobid, null); try { const fileType = getFileType(uploadedFile); // Images are always processed synchronously (single page) if (fileType === 'image') { const processedData = await processSinglePageDocument(uploadedFile.buffer); - const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: request }); - response.status(200).send({ + const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req }); + logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ...processedData, billForm }); + + res.status(200).send({ success: true, status: 'COMPLETED', data: { ...processedData, billForm }, @@ -83,9 +86,9 @@ async function handleBillOcr(request, response) { if (pageCount === 1) { // Process synchronously for single-page documents const processedData = await processSinglePageDocument(uploadedFile.buffer); - const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: request }); - //const billResult = await generateBillFormData({ result, }); - response.status(200).send({ + const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req }); + logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ...processedData, billForm }); + res.status(200).send({ success: true, status: 'COMPLETED', data: { ...processedData, billForm }, @@ -94,8 +97,9 @@ async function handleBillOcr(request, response) { } else { // Start the Textract job (non-blocking) for multi-page documents const jobInfo = await startTextractJob(uploadedFile.buffer, { jobid, bodyshopid, partsorderid }); + logger.log("bill-ocr-multipage-start", "DEBUG", req.user.email, jobid, jobInfo); - response.status(202).send({ + res.status(202).send({ success: true, textractJobId: jobInfo.jobId, message: 'Invoice processing started', @@ -103,32 +107,35 @@ async function handleBillOcr(request, response) { }); } } else { - response.status(400).send({ + logger.log("bill-ocr-unsupported-filetype", "WARN", req.user.email, jobid, { fileType }); + + res.status(400).send({ error: 'Unsupported file type', message: 'Please upload a PDF or supported image file (JPEG, PNG, TIFF)' }); } } catch (error) { - console.error('Error starting invoice processing:', error); - response.status(500).send({ + logger.log("bill-ocr-error", "ERROR", req.user.email, jobid, { error: error.message, stack: error.stack }); + res.status(500).send({ error: 'Failed to start invoice processing', message: error.message }); } } -async function handleBillOcrStatus(request, response) { - const { textractJobId } = request.params; +async function handleBillOcrStatus(req, res) { + const { textractJobId } = req.params; if (!textractJobId) { - console.log('No textractJobId found in params'); - response.status(400).send({ error: 'Job ID is required' }); + logger.log("bill-ocr-status-error", "WARN", req.user.email, null, { error: 'No textractJobId found in params' }); + res.status(400).send({ error: 'Job ID is required' }); + res.status(400).send({ error: 'Job ID is required' }); return; } const jobStatus = await getTextractJob({ redisPubClient, textractJobId }); if (!jobStatus) { - response.status(404).send({ error: 'Job not found' }); + res.status(404).send({ error: 'Job not found' }); return; } @@ -143,8 +150,9 @@ async function handleBillOcrStatus(request, response) { jobid: jobStatus.context.jobid, bodyshopid: jobStatus.context.bodyshopid, partsorderid: jobStatus.context.partsorderid, - req: request // Now we have request context! + req: req // Now we have request context! }); + logger.log("bill-ocr-multipage-complete", "DEBUG", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, billForm }); // Cache the billForm back to Redis for future requests await setTextractJob({ @@ -159,7 +167,9 @@ async function handleBillOcrStatus(request, response) { } }); } catch (error) { - response.status(500).send({ + logger.log("bill-ocr-multipage-error", "ERROR", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, error: error.message, stack: error.stack }); + + res.status(500).send({ status: 'COMPLETED', error: 'Data processed but failed to generate bill form', message: error.message, @@ -169,7 +179,7 @@ async function handleBillOcrStatus(request, response) { } } - response.status(200).send({ + res.status(200).send({ status: 'COMPLETED', data: { ...jobStatus.data, @@ -177,12 +187,14 @@ async function handleBillOcrStatus(request, response) { } }); } else if (jobStatus.status === 'FAILED') { - response.status(500).send({ + logger.log("bill-ocr-multipage-failed", "ERROR", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, error: jobStatus.error, }); + + res.status(500).send({ status: 'FAILED', error: jobStatus.error }); } else { - response.status(200).send({ + res.status(200).send({ status: jobStatus.status }); } @@ -307,22 +319,51 @@ async function processSQSMessages() { console.log('Processing', result.Messages.length, 'messages from SQS'); for (const message of result.Messages) { try { - //TODO: Add environment level filtering here. - await handleTextractNotification(message); + // Environment-level filtering: check if this message belongs to this environment + const shouldProcess = await shouldProcessMessage(message); - // Delete message after successful processing - const deleteCommand = new DeleteMessageCommand({ - QueueUrl: queueUrl, - ReceiptHandle: message.ReceiptHandle - }); - await sqsClient.send(deleteCommand); + if (shouldProcess) { + await handleTextractNotification(message); + // Delete message after successful processing + const deleteCommand = new DeleteMessageCommand({ + QueueUrl: queueUrl, + ReceiptHandle: message.ReceiptHandle + }); + await sqsClient.send(deleteCommand); + } else { + console.log('Ignoring message - job not found in this environment'); + } } catch (error) { - console.error('Error processing message:', error); + + logger.log("bill-ocr-sqs-processing-error", "ERROR", "api", null, { message, error: error.message, stack: error.stack }); + } } } } catch (error) { - console.error('Error receiving SQS messages:', error); + logger.log("bill-ocr-sqs-receiving-error", "ERROR", "api", null, { error: error.message, stack: error.stack }); + + } +} + +/** + * Check if a message should be processed by this environment + * @param {Object} message - SQS message + * @returns {Promise} + */ +async function shouldProcessMessage(message) { + try { + const body = JSON.parse(message.Body); + const snsMessage = JSON.parse(body.Message); + const textractJobId = snsMessage.JobId; + + // Check if job exists in Redis for this environment (using environment-specific prefix) + const exists = await jobExists(textractJobId); + return exists; + } catch (error) { + console.error('Error checking if message should be processed:', error); + // If we can't parse the message, don't process it + return false; } } @@ -332,30 +373,22 @@ async function handleTextractNotification(message) { try { snsMessage = JSON.parse(body.Message); } catch (error) { - //Delete the message so it doesn't clog the queue - const deleteCommand = new DeleteMessageCommand({ - QueueUrl: process.env.AWS_TEXTRACT_SQS_QUEUE_URL, - ReceiptHandle: message.ReceiptHandle - }); - await sqsClient.send(deleteCommand); console.error('Error parsing SNS message:', error); - console.log('Message Deleted:', body); + console.log('Invalid message format:', body); return; } const textractJobId = snsMessage.JobId; const status = snsMessage.Status; - // Check if job exists in Redis - const exists = await jobExists(textractJobId); + // Get job info from Redis + const jobInfo = await getTextractJob({ redisPubClient, textractJobId }); - if (!exists) { - console.warn(`Job not found for Textract job ID: ${textractJobId}`); + if (!jobInfo) { + console.warn(`Job info not found in Redis for Textract job ID: ${textractJobId}`); return; } - const jobInfo = await getTextractJob({ redisPubClient, textractJobId }); - if (status === 'SUCCEEDED') { // Retrieve the results const { processedData, originalResponse } = await retrieveTextractResults(textractJobId);