| 1 | import * as Lambda from "@/AWS/Lambda"; |
| 2 | import * as S3 from "@/AWS/S3"; |
| 3 | import * as Textract from "@/AWS/Textract"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 9 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 10 | import path from "pathe"; |
| 11 | import { HELLO_PNG_BASE64 } from "./constants.ts"; |
| 12 | |
| 13 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 14 | |
| 15 | // Deterministic checked-in test input, decoded once at module init. |
| 16 | const pngBytes = Buffer.from(HELLO_PNG_BASE64, "base64"); |
| 17 | |
| 18 | /** Key of the seeded input object the async Start* routes analyze. */ |
| 19 | export const INPUT_KEY = "textract/hello.png"; |
| 20 | |
| 21 | export class TextractTestFunction extends Lambda.Function<Lambda.Function>()( |
| 22 | "TextractTestFunction", |
| 23 | ) {} |
| 24 | |
| 25 | export 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 | ); |