1import * as Lambda from "@/AWS/Lambda";
2import * as S3 from "@/AWS/S3";
3import * as Textract from "@/AWS/Textract";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Schedule from "effect/Schedule";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11import { HELLO_PNG_BASE64 } from "./constants.ts";
12
13const main = path.resolve(import.meta.dirname, "handler.ts");
14
15// Deterministic checked-in test input, decoded once at module init.
16const pngBytes = Buffer.from(HELLO_PNG_BASE64, "base64");
17
18/** Key of the seeded input object the async Start* routes analyze. */
19export const INPUT_KEY = "textract/hello.png";
20
21export class TextractTestFunction extends Lambda.Function<Lambda.Function>()(
22 "TextractTestFunction",
23) {}
24
25export default TextractTestFunction.make(
26 {
27 main,
28 functionUrl: true,
29 // Each sync analysis inference takes a few seconds; routes run several.
30 timeout: Duration.seconds(60),
31 // The bundled Textract schema graph is large — give headroom over the
32 // 128 MB default.
33 memorySize: 512,
34 },
35 Effect.gen(function* () {
36 const bucket = yield* S3.Bucket("TextractInputBucket", {
37 forceDestroy: true,
38 });
39 const adapter = yield* Textract.Adapter("TextractTestAdapter", {
40 featureTypes: ["QUERIES"],
41 description: "alchemy textract bindings fixture",
42 });
43
44 // Seeding route writes the input object; Textract's async Start* APIs
45 // read the S3 input with the CALLER's credentials, so the function
46 // itself also needs s3:GetObject on the bucket.
47 const putObject = yield* S3.PutObject(bucket);
48 yield* S3.GetObject(bucket);
49
50 // --- synchronous analysis ---
51 const analyzeDocument = yield* Textract.AnalyzeDocument();
52 const analyzeExpense = yield* Textract.AnalyzeExpense();
53 const analyzeID = yield* Textract.AnalyzeID();
54
55 // --- asynchronous jobs ---
56 const startDocumentTextDetection =
57 yield* Textract.StartDocumentTextDetection();
58 const getDocumentTextDetection = yield* Textract.GetDocumentTextDetection();
59 const startDocumentAnalysis = yield* Textract.StartDocumentAnalysis();
60 const getDocumentAnalysis = yield* Textract.GetDocumentAnalysis();
61 const startExpenseAnalysis = yield* Textract.StartExpenseAnalysis();
62 const getExpenseAnalysis = yield* Textract.GetExpenseAnalysis();
63 const startLendingAnalysis = yield* Textract.StartLendingAnalysis();
64 const getLendingAnalysis = yield* Textract.GetLendingAnalysis();
65 const getLendingAnalysisSummary =
66 yield* Textract.GetLendingAnalysisSummary();
67
68 // --- adapter management ---
69 const listAdapters = yield* Textract.ListAdapters();
70 const getAdapter = yield* Textract.GetAdapter(adapter);
71 const listAdapterVersions = yield* Textract.ListAdapterVersions(adapter);
72 const getAdapterVersion = yield* Textract.GetAdapterVersion(adapter);
73 const createAdapterVersion = yield* Textract.CreateAdapterVersion(adapter);
74 const deleteAdapterVersion = yield* Textract.DeleteAdapterVersion(adapter);
75
76 const BucketName = yield* bucket.bucketName;
77
78 const lineTexts = (blocks: { BlockType?: string; Text?: string }[]) =>
79 blocks.filter((b) => b.BlockType === "LINE").map((b) => b.Text);
80
81 // Textract's adapter management APIs throttle at ~1 TPS; bounded backoff
82 // absorbs the bursts the adapter routes emit.
83 const throttleRetry = <A, E extends { readonly _tag: string }>(
84 effect: Effect.Effect<A, E>,
85 ) =>
86 effect.pipe(
87 Effect.retry({
88 while: (e): boolean =>
89 e._tag === "ProvisionedThroughputExceededException" ||
90 e._tag === "ThrottlingException",
91 schedule: Schedule.exponential("1 second"),
92 times: 5,
93 }),
94 );
95
96 return {
97 fetch: Effect.gen(function* () {
98 const request = yield* HttpServerRequest;
99 const url = new URL(request.originalUrl);
100 const pathname = url.pathname;
101 const jobId = url.searchParams.get("jobId") ?? "";
102 // BucketName is an Output accessor — resolve it per invocation.
103 const Bucket = yield* BucketName;
104 const s3Input = {
105 S3Object: { Bucket, Name: INPUT_KEY },
106 };
107
108 // Cheap readiness route — no AWS call.
109 if (request.method === "GET" && pathname === "/ping") {
110 return yield* HttpServerResponse.json({ ok: true });
111 }
112
113 if (request.method === "POST" && pathname === "/seed") {
114 yield* putObject({
115 Key: INPUT_KEY,
116 Body: pngBytes,
117 ContentType: "image/png",
118 });
119 return yield* HttpServerResponse.json({ seeded: true });
120 }
121
122 // --- synchronous analysis ---
123 if (request.method === "GET" && pathname === "/analyze-document") {
124 const result = yield* analyzeDocument({
125 Document: { Bytes: pngBytes },
126 FeatureTypes: ["TABLES", "FORMS"],
127 });
128 return yield* HttpServerResponse.json({
129 pages: result.DocumentMetadata?.Pages,
130 lines: lineTexts(result.Blocks ?? []),
131 });
132 }
133
134 if (request.method === "GET" && pathname === "/analyze-expense") {
135 const result = yield* analyzeExpense({
136 Document: { Bytes: pngBytes },
137 });
138 return yield* HttpServerResponse.json({
139 pages: result.DocumentMetadata?.Pages,
140 expenseDocuments: result.ExpenseDocuments?.length ?? 0,
141 });
142 }
143
144 if (request.method === "GET" && pathname === "/analyze-id") {
145 const result = yield* analyzeID({
146 DocumentPages: [{ Bytes: pngBytes }],
147 });
148 return yield* HttpServerResponse.json({
149 pages: result.DocumentMetadata?.Pages,
150 identityDocuments: result.IdentityDocuments?.length ?? 0,
151 });
152 }
153
154 // --- asynchronous jobs ---
155 if (request.method === "POST" && pathname === "/start-text-detection") {
156 const result = yield* startDocumentTextDetection({
157 DocumentLocation: s3Input,
158 });
159 return yield* HttpServerResponse.json({ jobId: result.JobId });
160 }
161
162 if (request.method === "GET" && pathname === "/get-text-detection") {
163 const result = yield* getDocumentTextDetection({ JobId: jobId });
164 return yield* HttpServerResponse.json({
165 jobStatus: result.JobStatus,
166 lines: lineTexts(result.Blocks ?? []),
167 });
168 }
169
170 if (request.method === "POST" && pathname === "/start-analysis") {
171 const result = yield* startDocumentAnalysis({
172 DocumentLocation: s3Input,
173 FeatureTypes: ["TABLES"],
174 });
175 return yield* HttpServerResponse.json({ jobId: result.JobId });
176 }
177
178 if (request.method === "GET" && pathname === "/get-analysis") {
179 const result = yield* getDocumentAnalysis({ JobId: jobId });
180 return yield* HttpServerResponse.json({
181 jobStatus: result.JobStatus,
182 blocks: result.Blocks?.length ?? 0,
183 });
184 }
185
186 if (request.method === "POST" && pathname === "/start-expense") {
187 const result = yield* startExpenseAnalysis({
188 DocumentLocation: s3Input,
189 });
190 return yield* HttpServerResponse.json({ jobId: result.JobId });
191 }
192
193 if (request.method === "GET" && pathname === "/get-expense") {
194 const result = yield* getExpenseAnalysis({ JobId: jobId });
195 return yield* HttpServerResponse.json({
196 jobStatus: result.JobStatus,
197 expenseDocuments: result.ExpenseDocuments?.length ?? 0,
198 });
199 }
200
201 if (request.method === "POST" && pathname === "/start-lending") {
202 const result = yield* startLendingAnalysis({
203 DocumentLocation: s3Input,
204 });
205 return yield* HttpServerResponse.json({ jobId: result.JobId });
206 }
207
208 if (request.method === "GET" && pathname === "/get-lending") {
209 const result = yield* getLendingAnalysis({ JobId: jobId });
210 return yield* HttpServerResponse.json({
211 jobStatus: result.JobStatus,
212 results: result.Results?.length ?? 0,
213 });
214 }
215
216 if (request.method === "GET" && pathname === "/get-lending-summary") {
217 const result = yield* getLendingAnalysisSummary({ JobId: jobId });
218 return yield* HttpServerResponse.json({
219 jobStatus: result.JobStatus,
220 documentGroups: result.Summary?.DocumentGroups?.length ?? 0,
221 });
222 }
223
224 // --- adapter management ---
225 if (request.method === "GET" && pathname === "/adapters") {
226 const info = yield* throttleRetry(getAdapter());
227 const listed = yield* throttleRetry(listAdapters());
228 const versions = yield* throttleRetry(listAdapterVersions());
229 return yield* HttpServerResponse.json({
230 adapterName: info.AdapterName,
231 featureTypes: info.FeatureTypes,
232 listed: (listed.Adapters ?? []).map((a) => a.AdapterName),
233 versionsCount: versions.AdapterVersions?.length ?? 0,
234 });
235 }
236
237 // Typed-error probes — well-formed-but-nonexistent identifiers drive
238 // the typed not-found/validation paths. An IAM gap would surface
239 // AccessDeniedException (a 500 through the handler's orDie), so a
240 // typed tag here proves the grant end-to-end.
241 if (
242 request.method === "GET" &&
243 pathname === "/adapter-version-probes"
244 ) {
245 const getVersionProbe = yield* throttleRetry(
246 getAdapterVersion({ AdapterVersion: "999" }),
247 ).pipe(
248 Effect.map(() => "unexpected-success"),
249 Effect.catchTag(
250 ["ResourceNotFoundException", "ValidationException"],
251 (e) => Effect.succeed(e._tag),
252 ),
253 );
254 // DeleteAdapterVersion is idempotent — deleting a nonexistent
255 // version returns success, which proves the grant just as well.
256 const deleteVersionProbe = yield* throttleRetry(
257 deleteAdapterVersion({ AdapterVersion: "999" }),
258 ).pipe(
259 Effect.map(() => "success"),
260 Effect.catchTag(
261 [
262 "ResourceNotFoundException",
263 "ValidationException",
264 "ConflictException",
265 ],
266 (e) => Effect.succeed(e._tag),
267 ),
268 );
269 const createVersionProbe = yield* throttleRetry(
270 createAdapterVersion({
271 DatasetConfig: {
272 ManifestS3Object: {
273 Bucket,
274 Name: "missing-manifest.jsonl",
275 },
276 },
277 OutputConfig: {
278 S3Bucket: Bucket,
279 S3Prefix: "adapter-training/",
280 },
281 }),
282 ).pipe(
283 Effect.map(() => "unexpected-success"),
284 Effect.catchTag(
285 [
286 "InvalidS3ObjectException",
287 "InvalidParameterException",
288 "ValidationException",
289 "LimitExceededException",
290 ],
291 (e) => Effect.succeed(e._tag),
292 ),
293 );
294 return yield* HttpServerResponse.json({
295 getVersionProbe,
296 deleteVersionProbe,
297 createVersionProbe,
298 });
299 }
300
301 return yield* HttpServerResponse.json(
302 { error: "Not found", method: request.method, pathname },
303 { status: 404 },
304 );
305 }).pipe(Effect.orDie),
306 };
307 }).pipe(
308 Effect.provide(
309 Layer.mergeAll(
310 S3.PutObjectHttp,
311 S3.GetObjectHttp,
312 Textract.AnalyzeDocumentHttp,
313 Textract.AnalyzeExpenseHttp,
314 Textract.AnalyzeIDHttp,
315 Textract.StartDocumentTextDetectionHttp,
316 Textract.GetDocumentTextDetectionHttp,
317 Textract.StartDocumentAnalysisHttp,
318 Textract.GetDocumentAnalysisHttp,
319 Textract.StartExpenseAnalysisHttp,
320 Textract.GetExpenseAnalysisHttp,
321 Textract.StartLendingAnalysisHttp,
322 Textract.GetLendingAnalysisHttp,
323 Textract.GetLendingAnalysisSummaryHttp,
324 Textract.ListAdaptersHttp,
325 Textract.GetAdapterHttp,
326 Textract.ListAdapterVersionsHttp,
327 Textract.GetAdapterVersionHttp,
328 Textract.CreateAdapterVersionHttp,
329 Textract.DeleteAdapterVersionHttp,
330 ),
331 ),
332 ),
333);