Skip to content

Commit b686111

Browse files
authored
feat(textract): migrate to AWS SDK, add AnalyzeExpense and AnalyzeID (#5456)
* feat(textract): migrate to AWS SDK, add AnalyzeExpense and AnalyzeID - Replace hand-rolled AWS SigV4 signing with @aws-sdk/client-textract, matching sibling AWS integrations (secrets_manager, s3, sts) - Add Analyze Expense operation (invoice/receipt structured extraction) via AnalyzeExpense/StartExpenseAnalysis+GetExpenseAnalysis - Add Analyze Identity Document operation (AnalyzeID) with optional back-of-ID page - Add an operation selector to the textract_v2 block; defaults to the existing Analyze Document behavior for backward compatibility - Add tests for tool body/response mapping and route-level AWS response normalization * fix(textract): forward URL documents and fix ambiguous error status - textract_analyze_expense/textract_analyze_id now fall back to filePath/filePathBack when the document input is a URL string rather than an uploaded file object, so advanced "File reference" URL inputs actually reach the API (Cursor Bugbot) - mapTextractSdkError defaults to 500 (not 400) when the AWS SDK error has no HTTP status, since that implies a server-side/network failure rather than a bad request (Greptile) * fix(textract): stop stale processingMode from hiding ID document fields - Front-document fields (fileUpload/fileReference) are shared across all 3 operations; gate them with a values-aware condition so switching to Analyze Identity Document keeps them visible even if a stale processingMode='async' is left over from a previous operation - S3 URI field now also requires operation !== 'analyze_id', since that operation never supports S3 input * fix(textract): preserve first-page metadata across async pagination pollTextractJob's merge callbacks spread only the latest page, dropping any field (DocumentMetadata, model version) the first page had but a follow-up NextToken page omits. Merge accumulated first so later pages only override fields they actually return. * fix(textract): pass through the real upstream status for filePath fetch failures fetchDocumentBytes hardcoded 400 for any non-OK response from a document URL, masking transient 5xx failures from the document host as client errors and blocking tool-execution retries. Use the actual response status instead. * chore(textract): drop redundant inline comments
1 parent 017e8ca commit b686111

18 files changed

Lines changed: 2168 additions & 607 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { normalizeExpenseDocuments } from '@/app/api/tools/textract/analyze-expense/route'
6+
7+
describe('normalizeExpenseDocuments', () => {
8+
it('maps a documented AWS AnalyzeExpense response shape', () => {
9+
const result = normalizeExpenseDocuments([
10+
{
11+
ExpenseIndex: 1,
12+
SummaryFields: [
13+
{
14+
Type: { Text: 'VENDOR_NAME', Confidence: 98.1 },
15+
ValueDetection: { Text: 'Acme Corp', Confidence: 97.5 },
16+
LabelDetection: { Text: 'Vendor', Confidence: 90 },
17+
PageNumber: 1,
18+
Currency: { Code: 'USD', Confidence: 95 },
19+
GroupProperties: [{ Id: 'g1', Types: ['VENDOR'] }],
20+
},
21+
],
22+
LineItemGroups: [
23+
{
24+
LineItemGroupIndex: 1,
25+
LineItems: [
26+
{
27+
LineItemExpenseFields: [
28+
{
29+
Type: { Text: 'ITEM', Confidence: 91 },
30+
ValueDetection: { Text: 'Widget', Confidence: 93 },
31+
},
32+
],
33+
},
34+
],
35+
},
36+
],
37+
},
38+
])
39+
40+
expect(result).toEqual([
41+
{
42+
expenseIndex: 1,
43+
summaryFields: [
44+
{
45+
type: { text: 'VENDOR_NAME', confidence: 98.1 },
46+
valueDetection: { text: 'Acme Corp', confidence: 97.5 },
47+
labelDetection: { text: 'Vendor', confidence: 90 },
48+
pageNumber: 1,
49+
currency: { code: 'USD', confidence: 95 },
50+
groupProperties: [{ id: 'g1', types: ['VENDOR'] }],
51+
},
52+
],
53+
lineItemGroups: [
54+
{
55+
lineItemGroupIndex: 1,
56+
lineItems: [
57+
{
58+
lineItemExpenseFields: [
59+
{
60+
type: { text: 'ITEM', confidence: 91 },
61+
valueDetection: { text: 'Widget', confidence: 93 },
62+
labelDetection: undefined,
63+
pageNumber: undefined,
64+
currency: undefined,
65+
groupProperties: undefined,
66+
},
67+
],
68+
},
69+
],
70+
},
71+
],
72+
},
73+
])
74+
})
75+
76+
it('defaults missing arrays to empty arrays', () => {
77+
expect(normalizeExpenseDocuments([{ ExpenseIndex: 0 }])).toEqual([
78+
{ expenseIndex: 0, summaryFields: [], lineItemGroups: [] },
79+
])
80+
})
81+
})
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import {
2+
AnalyzeExpenseCommand,
3+
type ExpenseDocument,
4+
GetExpenseAnalysisCommand,
5+
StartExpenseAnalysisCommand,
6+
TextractClient,
7+
} from '@aws-sdk/client-textract'
8+
import { createLogger } from '@sim/logger'
9+
import type { NextRequest } from 'next/server'
10+
import { NextResponse } from 'next/server'
11+
import { textractAnalyzeExpenseContract } from '@/lib/api/contracts/tools/media/document-parse'
12+
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
13+
import { checkInternalAuth } from '@/lib/auth/hybrid'
14+
import { generateRequestId } from '@/lib/core/utils/request'
15+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
16+
import {
17+
mapTextractSdkError,
18+
parseS3Uri,
19+
pollTextractJob,
20+
resolveDocumentInput,
21+
textractErrorResponse,
22+
} from '@/app/api/tools/textract/shared'
23+
24+
export const dynamic = 'force-dynamic'
25+
/** Mirrors maxDuration in ../parse/route.ts — see that file's TSDoc for details. */
26+
export const maxDuration = 5400
27+
28+
const logger = createLogger('TextractAnalyzeExpenseAPI')
29+
30+
/** Response shape shared by AnalyzeExpense and its async Get* counterpart. */
31+
interface TextractExpenseResult {
32+
JobStatus?: string
33+
StatusMessage?: string
34+
NextToken?: string
35+
ExpenseDocuments?: ExpenseDocument[]
36+
DocumentMetadata?: { Pages?: number }
37+
AnalyzeExpenseModelVersion?: string
38+
}
39+
40+
export function normalizeExpenseField(field: {
41+
Type?: { Text?: string; Confidence?: number }
42+
ValueDetection?: { Text?: string; Confidence?: number }
43+
LabelDetection?: { Text?: string; Confidence?: number }
44+
PageNumber?: number
45+
Currency?: { Code?: string; Confidence?: number }
46+
GroupProperties?: { Id?: string; Types?: string[] }[]
47+
}) {
48+
return {
49+
type: { text: field.Type?.Text, confidence: field.Type?.Confidence },
50+
valueDetection: {
51+
text: field.ValueDetection?.Text,
52+
confidence: field.ValueDetection?.Confidence,
53+
},
54+
labelDetection: field.LabelDetection
55+
? { text: field.LabelDetection.Text, confidence: field.LabelDetection.Confidence }
56+
: undefined,
57+
pageNumber: field.PageNumber,
58+
currency: field.Currency
59+
? { code: field.Currency.Code, confidence: field.Currency.Confidence }
60+
: undefined,
61+
groupProperties: field.GroupProperties?.map((group) => ({
62+
id: group.Id ?? '',
63+
types: group.Types ?? [],
64+
})),
65+
}
66+
}
67+
68+
export function normalizeExpenseDocuments(documents: ExpenseDocument[]) {
69+
return documents.map((doc) => ({
70+
expenseIndex: doc.ExpenseIndex,
71+
summaryFields: (doc.SummaryFields ?? []).map(normalizeExpenseField),
72+
lineItemGroups: (doc.LineItemGroups ?? []).map((group) => ({
73+
lineItemGroupIndex: group.LineItemGroupIndex,
74+
lineItems: (group.LineItems ?? []).map((item) => ({
75+
lineItemExpenseFields: (item.LineItemExpenseFields ?? []).map(normalizeExpenseField),
76+
})),
77+
})),
78+
}))
79+
}
80+
81+
export const POST = withRouteHandler(async (request: NextRequest) => {
82+
const requestId = generateRequestId()
83+
84+
try {
85+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
86+
if (!authResult.success || !authResult.userId) {
87+
logger.warn(`[${requestId}] Unauthorized Textract analyze-expense attempt`, {
88+
error: authResult.error || 'Missing userId',
89+
})
90+
return NextResponse.json(
91+
{ success: false, error: authResult.error || 'Unauthorized' },
92+
{ status: 401 }
93+
)
94+
}
95+
const userId = authResult.userId
96+
97+
const parsed = await parseRequest(
98+
textractAnalyzeExpenseContract,
99+
request,
100+
{},
101+
{
102+
validationErrorResponse: (error) => {
103+
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
104+
return NextResponse.json(
105+
{
106+
success: false,
107+
error: getValidationErrorMessage(error, 'Invalid request data'),
108+
details: error.issues,
109+
},
110+
{ status: 400 }
111+
)
112+
},
113+
}
114+
)
115+
if (!parsed.success) return parsed.response
116+
117+
const validatedData = parsed.data.body
118+
const processingMode = validatedData.processingMode || 'sync'
119+
120+
logger.info(`[${requestId}] Textract analyze-expense request`, {
121+
processingMode,
122+
hasFile: Boolean(validatedData.file),
123+
hasS3Uri: Boolean(validatedData.s3Uri),
124+
userId,
125+
})
126+
127+
const client = new TextractClient({
128+
region: validatedData.region,
129+
credentials: {
130+
accessKeyId: validatedData.accessKeyId,
131+
secretAccessKey: validatedData.secretAccessKey,
132+
},
133+
})
134+
135+
if (processingMode === 'async') {
136+
if (!validatedData.s3Uri) {
137+
return NextResponse.json(
138+
{
139+
success: false,
140+
error: 'S3 URI is required for multi-page processing (s3://bucket/key)',
141+
},
142+
{ status: 400 }
143+
)
144+
}
145+
146+
const { bucket, key } = parseS3Uri(validatedData.s3Uri)
147+
logger.info(`[${requestId}] Starting async Textract expense analysis job`, {
148+
s3Bucket: bucket,
149+
s3Key: key,
150+
})
151+
152+
const { JobId: jobId } = await client.send(
153+
new StartExpenseAnalysisCommand({
154+
DocumentLocation: { S3Object: { Bucket: bucket, Name: key } },
155+
})
156+
)
157+
if (!jobId) {
158+
throw new Error('Failed to start Textract expense analysis job: No JobId returned')
159+
}
160+
logger.info(`[${requestId}] Async expense analysis job started`, { jobId })
161+
162+
const result = await pollTextractJob<TextractExpenseResult>(
163+
requestId,
164+
logger,
165+
(nextToken) =>
166+
client.send(new GetExpenseAnalysisCommand({ JobId: jobId, NextToken: nextToken })),
167+
(accumulated, page) => ({
168+
...accumulated,
169+
...page,
170+
ExpenseDocuments: [
171+
...(accumulated.ExpenseDocuments ?? []),
172+
...(page.ExpenseDocuments ?? []),
173+
],
174+
})
175+
)
176+
177+
return NextResponse.json({
178+
success: true,
179+
output: {
180+
expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []),
181+
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
182+
modelVersion: result.AnalyzeExpenseModelVersion,
183+
},
184+
})
185+
}
186+
187+
const resolved = await resolveDocumentInput(
188+
{ file: validatedData.file, filePath: validatedData.filePath },
189+
userId,
190+
requestId,
191+
logger
192+
)
193+
if (!resolved.ok) return resolved.response
194+
const { bytes, isPdf } = resolved.document
195+
196+
let result: TextractExpenseResult
197+
try {
198+
result = await client.send(new AnalyzeExpenseCommand({ Document: { Bytes: bytes } }))
199+
} catch (error) {
200+
throw mapTextractSdkError(error, isPdf)
201+
}
202+
203+
logger.info(`[${requestId}] Textract analyze-expense successful`, {
204+
pageCount: result.DocumentMetadata?.Pages ?? 0,
205+
expenseDocumentCount: result.ExpenseDocuments?.length ?? 0,
206+
})
207+
208+
return NextResponse.json({
209+
success: true,
210+
output: {
211+
expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []),
212+
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
213+
},
214+
})
215+
} catch (error) {
216+
return textractErrorResponse(error, requestId, logger)
217+
}
218+
})
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { normalizeIdentityDocuments } from '@/app/api/tools/textract/analyze-id/route'
6+
7+
describe('normalizeIdentityDocuments', () => {
8+
it('maps a documented AWS AnalyzeID response shape', () => {
9+
const result = normalizeIdentityDocuments([
10+
{
11+
DocumentIndex: 1,
12+
IdentityDocumentFields: [
13+
{
14+
Type: { Text: 'FIRST_NAME', Confidence: 99 },
15+
ValueDetection: { Text: 'Jane', Confidence: 98 },
16+
},
17+
{
18+
Type: {
19+
Text: 'DATE_OF_BIRTH',
20+
Confidence: 97,
21+
NormalizedValue: { Value: '1990-01-01', ValueType: 'Date' },
22+
},
23+
ValueDetection: {
24+
Text: '01/01/1990',
25+
Confidence: 96,
26+
NormalizedValue: { Value: '1990-01-01T00:00:00', ValueType: 'Date' },
27+
},
28+
},
29+
],
30+
},
31+
])
32+
33+
expect(result).toEqual([
34+
{
35+
documentIndex: 1,
36+
identityDocumentFields: [
37+
{
38+
type: { text: 'FIRST_NAME', confidence: 99, normalizedValue: undefined },
39+
valueDetection: { text: 'Jane', confidence: 98, normalizedValue: undefined },
40+
},
41+
{
42+
type: {
43+
text: 'DATE_OF_BIRTH',
44+
confidence: 97,
45+
normalizedValue: { value: '1990-01-01', valueType: 'Date' },
46+
},
47+
valueDetection: {
48+
text: '01/01/1990',
49+
confidence: 96,
50+
normalizedValue: { value: '1990-01-01T00:00:00', valueType: 'Date' },
51+
},
52+
},
53+
],
54+
},
55+
])
56+
})
57+
58+
it('defaults missing fields to an empty array', () => {
59+
expect(normalizeIdentityDocuments([{ DocumentIndex: 0 }])).toEqual([
60+
{ documentIndex: 0, identityDocumentFields: [] },
61+
])
62+
})
63+
})

0 commit comments

Comments
 (0)