Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions apps/sim/app/api/tools/textract/analyze-expense/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { normalizeExpenseDocuments } from '@/app/api/tools/textract/analyze-expense/route'

describe('normalizeExpenseDocuments', () => {
it('maps a documented AWS AnalyzeExpense response shape', () => {
const result = normalizeExpenseDocuments([
{
ExpenseIndex: 1,
SummaryFields: [
{
Type: { Text: 'VENDOR_NAME', Confidence: 98.1 },
ValueDetection: { Text: 'Acme Corp', Confidence: 97.5 },
LabelDetection: { Text: 'Vendor', Confidence: 90 },
PageNumber: 1,
Currency: { Code: 'USD', Confidence: 95 },
GroupProperties: [{ Id: 'g1', Types: ['VENDOR'] }],
},
],
LineItemGroups: [
{
LineItemGroupIndex: 1,
LineItems: [
{
LineItemExpenseFields: [
{
Type: { Text: 'ITEM', Confidence: 91 },
ValueDetection: { Text: 'Widget', Confidence: 93 },
},
],
},
],
},
],
},
])

expect(result).toEqual([
{
expenseIndex: 1,
summaryFields: [
{
type: { text: 'VENDOR_NAME', confidence: 98.1 },
valueDetection: { text: 'Acme Corp', confidence: 97.5 },
labelDetection: { text: 'Vendor', confidence: 90 },
pageNumber: 1,
currency: { code: 'USD', confidence: 95 },
groupProperties: [{ id: 'g1', types: ['VENDOR'] }],
},
],
lineItemGroups: [
{
lineItemGroupIndex: 1,
lineItems: [
{
lineItemExpenseFields: [
{
type: { text: 'ITEM', confidence: 91 },
valueDetection: { text: 'Widget', confidence: 93 },
labelDetection: undefined,
pageNumber: undefined,
currency: undefined,
groupProperties: undefined,
},
],
},
],
},
],
},
])
})

it('defaults missing arrays to empty arrays', () => {
expect(normalizeExpenseDocuments([{ ExpenseIndex: 0 }])).toEqual([
{ expenseIndex: 0, summaryFields: [], lineItemGroups: [] },
])
})
})
218 changes: 218 additions & 0 deletions apps/sim/app/api/tools/textract/analyze-expense/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import {
AnalyzeExpenseCommand,
type ExpenseDocument,
GetExpenseAnalysisCommand,
StartExpenseAnalysisCommand,
TextractClient,
} from '@aws-sdk/client-textract'
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { textractAnalyzeExpenseContract } from '@/lib/api/contracts/tools/media/document-parse'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mapTextractSdkError,
parseS3Uri,
pollTextractJob,
resolveDocumentInput,
textractErrorResponse,
} from '@/app/api/tools/textract/shared'

export const dynamic = 'force-dynamic'
/** Mirrors maxDuration in ../parse/route.ts — see that file's TSDoc for details. */
export const maxDuration = 5400

const logger = createLogger('TextractAnalyzeExpenseAPI')

/** Response shape shared by AnalyzeExpense and its async Get* counterpart. */
interface TextractExpenseResult {
JobStatus?: string
StatusMessage?: string
NextToken?: string
ExpenseDocuments?: ExpenseDocument[]
DocumentMetadata?: { Pages?: number }
AnalyzeExpenseModelVersion?: string
}

export function normalizeExpenseField(field: {
Type?: { Text?: string; Confidence?: number }
ValueDetection?: { Text?: string; Confidence?: number }
LabelDetection?: { Text?: string; Confidence?: number }
PageNumber?: number
Currency?: { Code?: string; Confidence?: number }
GroupProperties?: { Id?: string; Types?: string[] }[]
}) {
return {
type: { text: field.Type?.Text, confidence: field.Type?.Confidence },
valueDetection: {
text: field.ValueDetection?.Text,
confidence: field.ValueDetection?.Confidence,
},
labelDetection: field.LabelDetection
? { text: field.LabelDetection.Text, confidence: field.LabelDetection.Confidence }
: undefined,
pageNumber: field.PageNumber,
currency: field.Currency
? { code: field.Currency.Code, confidence: field.Currency.Confidence }
: undefined,
groupProperties: field.GroupProperties?.map((group) => ({
id: group.Id ?? '',
types: group.Types ?? [],
})),
}
}

export function normalizeExpenseDocuments(documents: ExpenseDocument[]) {
return documents.map((doc) => ({
expenseIndex: doc.ExpenseIndex,
summaryFields: (doc.SummaryFields ?? []).map(normalizeExpenseField),
lineItemGroups: (doc.LineItemGroups ?? []).map((group) => ({
lineItemGroupIndex: group.LineItemGroupIndex,
lineItems: (group.LineItems ?? []).map((item) => ({
lineItemExpenseFields: (item.LineItemExpenseFields ?? []).map(normalizeExpenseField),
})),
})),
}))
}

export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()

try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized Textract analyze-expense attempt`, {
error: authResult.error || 'Missing userId',
})
return NextResponse.json(
{ success: false, error: authResult.error || 'Unauthorized' },
{ status: 401 }
)
}
const userId = authResult.userId

const parsed = await parseRequest(
textractAnalyzeExpenseContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
return NextResponse.json(
{
success: false,
error: getValidationErrorMessage(error, 'Invalid request data'),
details: error.issues,
},
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response

const validatedData = parsed.data.body
const processingMode = validatedData.processingMode || 'sync'

logger.info(`[${requestId}] Textract analyze-expense request`, {
processingMode,
hasFile: Boolean(validatedData.file),
hasS3Uri: Boolean(validatedData.s3Uri),
userId,
})

const client = new TextractClient({
region: validatedData.region,
credentials: {
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
},
})

if (processingMode === 'async') {
if (!validatedData.s3Uri) {
return NextResponse.json(
{
success: false,
error: 'S3 URI is required for multi-page processing (s3://bucket/key)',
},
{ status: 400 }
)
}

const { bucket, key } = parseS3Uri(validatedData.s3Uri)
logger.info(`[${requestId}] Starting async Textract expense analysis job`, {
s3Bucket: bucket,
s3Key: key,
})

const { JobId: jobId } = await client.send(
new StartExpenseAnalysisCommand({
DocumentLocation: { S3Object: { Bucket: bucket, Name: key } },
})
)
if (!jobId) {
throw new Error('Failed to start Textract expense analysis job: No JobId returned')
}
logger.info(`[${requestId}] Async expense analysis job started`, { jobId })

const result = await pollTextractJob<TextractExpenseResult>(
requestId,
logger,
(nextToken) =>
client.send(new GetExpenseAnalysisCommand({ JobId: jobId, NextToken: nextToken })),
(accumulated, page) => ({
...accumulated,
...page,
ExpenseDocuments: [
...(accumulated.ExpenseDocuments ?? []),
...(page.ExpenseDocuments ?? []),
],
})
)

return NextResponse.json({
success: true,
output: {
expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []),
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
modelVersion: result.AnalyzeExpenseModelVersion,
},
})
}

const resolved = await resolveDocumentInput(
{ file: validatedData.file, filePath: validatedData.filePath },
userId,
requestId,
logger
)
if (!resolved.ok) return resolved.response
const { bytes, isPdf } = resolved.document

let result: TextractExpenseResult
try {
result = await client.send(new AnalyzeExpenseCommand({ Document: { Bytes: bytes } }))
} catch (error) {
throw mapTextractSdkError(error, isPdf)
}

logger.info(`[${requestId}] Textract analyze-expense successful`, {
pageCount: result.DocumentMetadata?.Pages ?? 0,
expenseDocumentCount: result.ExpenseDocuments?.length ?? 0,
})

return NextResponse.json({
success: true,
output: {
expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []),
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
},
})
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
} catch (error) {
return textractErrorResponse(error, requestId, logger)
}
})
63 changes: 63 additions & 0 deletions apps/sim/app/api/tools/textract/analyze-id/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { normalizeIdentityDocuments } from '@/app/api/tools/textract/analyze-id/route'

describe('normalizeIdentityDocuments', () => {
it('maps a documented AWS AnalyzeID response shape', () => {
const result = normalizeIdentityDocuments([
{
DocumentIndex: 1,
IdentityDocumentFields: [
{
Type: { Text: 'FIRST_NAME', Confidence: 99 },
ValueDetection: { Text: 'Jane', Confidence: 98 },
},
{
Type: {
Text: 'DATE_OF_BIRTH',
Confidence: 97,
NormalizedValue: { Value: '1990-01-01', ValueType: 'Date' },
},
ValueDetection: {
Text: '01/01/1990',
Confidence: 96,
NormalizedValue: { Value: '1990-01-01T00:00:00', ValueType: 'Date' },
},
},
],
},
])

expect(result).toEqual([
{
documentIndex: 1,
identityDocumentFields: [
{
type: { text: 'FIRST_NAME', confidence: 99, normalizedValue: undefined },
valueDetection: { text: 'Jane', confidence: 98, normalizedValue: undefined },
},
{
type: {
text: 'DATE_OF_BIRTH',
confidence: 97,
normalizedValue: { value: '1990-01-01', valueType: 'Date' },
},
valueDetection: {
text: '01/01/1990',
confidence: 96,
normalizedValue: { value: '1990-01-01T00:00:00', valueType: 'Date' },
},
},
],
},
])
})

it('defaults missing fields to an empty array', () => {
expect(normalizeIdentityDocuments([{ DocumentIndex: 0 }])).toEqual([
{ documentIndex: 0, identityDocumentFields: [] },
])
})
})
Loading
Loading