1import * as Comprehend from "@/AWS/Comprehend";
2import * as IAM from "@/AWS/IAM";
3import * as Lambda from "@/AWS/Lambda";
4import * as S3 from "@/AWS/S3";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Schedule from "effect/Schedule";
9import { HttpServerRequest } from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import path from "pathe";
12
13const main = path.resolve(import.meta.dirname, "handler.ts");
14
15// Deterministic fixture names (bucket names are account-global; this suite
16// owns them in the testing account).
17export const FIXTURE_BUCKET_NAME = "alchemy-test-comprehend-bindings";
18
19// Well-formed-but-nonexistent job id used to drive the typed
20// JobNotFoundException paths. An IAM gap would surface AccessDeniedException
21// (a 500 through the handler's orDie), so a typed not-found tag proves the
22// grant end-to-end.
23const BOGUS_JOB_ID = "00000000000000000000000000000000";
24
25// An S3 URI that fails Comprehend's server-side pattern validation — drives
26// the Start*Job bindings through their typed ValidationException path
27// without actually starting (and paying for) an async job per family.
28const INVALID_S3_URI = "invalid-uri";
29
30export class ComprehendTestFunction extends Lambda.Function<Lambda.Function>()(
31 "ComprehendTestFunction",
32) {}
33
34export default ComprehendTestFunction.make(
35 {
36 main,
37 functionUrl: true,
38 // The sentiment job lifecycle route retries StartSentimentDetectionJob
39 // through fresh-role IAM propagation (bounded ~40s).
40 timeout: Duration.seconds(120),
41 },
42 Effect.gen(function* () {
43 const bucket = yield* S3.Bucket("ComprehendInputBucket", {
44 bucketName: FIXTURE_BUCKET_NAME,
45 forceDestroy: true,
46 });
47 // The role Amazon Comprehend assumes to read input documents and write
48 // job results for the Start*Job bindings.
49 const dataAccessRole = yield* IAM.Role("ComprehendDataAccessRole", {
50 assumeRolePolicyDocument: {
51 Version: "2012-10-17",
52 Statement: [
53 {
54 Effect: "Allow",
55 Principal: { Service: "comprehend.amazonaws.com" },
56 Action: ["sts:AssumeRole"],
57 },
58 ],
59 },
60 inlinePolicies: {
61 s3Access: {
62 Version: "2012-10-17",
63 Statement: [
64 {
65 Effect: "Allow",
66 Action: ["s3:GetObject", "s3:ListBucket", "s3:PutObject"],
67 Resource: [
68 `arn:aws:s3:::${FIXTURE_BUCKET_NAME}`,
69 `arn:aws:s3:::${FIXTURE_BUCKET_NAME}/*`,
70 ],
71 },
72 ],
73 },
74 },
75 });
76 // Used by /classify-document to build a well-formed same-account
77 // endpoint ARN (a cross-account ARN returns NotAuthorizedException
78 // instead of the typed ResourceUnavailableException).
79 const roleArn = yield* dataAccessRole.roleArn;
80
81 const putObject = yield* S3.PutObject(bucket);
82
83 // --- real-time analysis bindings ---
84 const detectDominantLanguage = yield* Comprehend.DetectDominantLanguage();
85 const detectEntities = yield* Comprehend.DetectEntities();
86 const detectKeyPhrases = yield* Comprehend.DetectKeyPhrases();
87 const detectPiiEntities = yield* Comprehend.DetectPiiEntities();
88 const detectSentiment = yield* Comprehend.DetectSentiment();
89 const detectSyntax = yield* Comprehend.DetectSyntax();
90 const detectTargetedSentiment = yield* Comprehend.DetectTargetedSentiment();
91 const detectToxicContent = yield* Comprehend.DetectToxicContent();
92 const containsPiiEntities = yield* Comprehend.ContainsPiiEntities();
93 const classifyDocument = yield* Comprehend.ClassifyDocument();
94
95 // --- batch real-time analysis bindings ---
96 const batchDetectDominantLanguage =
97 yield* Comprehend.BatchDetectDominantLanguage();
98 const batchDetectEntities = yield* Comprehend.BatchDetectEntities();
99 const batchDetectKeyPhrases = yield* Comprehend.BatchDetectKeyPhrases();
100 const batchDetectSentiment = yield* Comprehend.BatchDetectSentiment();
101 const batchDetectSyntax = yield* Comprehend.BatchDetectSyntax();
102 const batchDetectTargetedSentiment =
103 yield* Comprehend.BatchDetectTargetedSentiment();
104
105 // --- async job bindings (role injection + PassRole grant) ---
106 const startDocumentClassificationJob =
107 yield* Comprehend.StartDocumentClassificationJob(dataAccessRole);
108 const startDominantLanguageDetectionJob =
109 yield* Comprehend.StartDominantLanguageDetectionJob(dataAccessRole);
110 const startEntitiesDetectionJob =
111 yield* Comprehend.StartEntitiesDetectionJob(dataAccessRole);
112 const startEventsDetectionJob =
113 yield* Comprehend.StartEventsDetectionJob(dataAccessRole);
114 const startKeyPhrasesDetectionJob =
115 yield* Comprehend.StartKeyPhrasesDetectionJob(dataAccessRole);
116 const startPiiEntitiesDetectionJob =
117 yield* Comprehend.StartPiiEntitiesDetectionJob(dataAccessRole);
118 const startSentimentDetectionJob =
119 yield* Comprehend.StartSentimentDetectionJob(dataAccessRole);
120 const startTargetedSentimentDetectionJob =
121 yield* Comprehend.StartTargetedSentimentDetectionJob(dataAccessRole);
122 const startTopicsDetectionJob =
123 yield* Comprehend.StartTopicsDetectionJob(dataAccessRole);
124
125 const describeDocumentClassificationJob =
126 yield* Comprehend.DescribeDocumentClassificationJob();
127 const describeDominantLanguageDetectionJob =
128 yield* Comprehend.DescribeDominantLanguageDetectionJob();
129 const describeEntitiesDetectionJob =
130 yield* Comprehend.DescribeEntitiesDetectionJob();
131 const describeEventsDetectionJob =
132 yield* Comprehend.DescribeEventsDetectionJob();
133 const describeKeyPhrasesDetectionJob =
134 yield* Comprehend.DescribeKeyPhrasesDetectionJob();
135 const describePiiEntitiesDetectionJob =
136 yield* Comprehend.DescribePiiEntitiesDetectionJob();
137 const describeSentimentDetectionJob =
138 yield* Comprehend.DescribeSentimentDetectionJob();
139 const describeTargetedSentimentDetectionJob =
140 yield* Comprehend.DescribeTargetedSentimentDetectionJob();
141 const describeTopicsDetectionJob =
142 yield* Comprehend.DescribeTopicsDetectionJob();
143
144 const listDocumentClassificationJobs =
145 yield* Comprehend.ListDocumentClassificationJobs();
146 const listDominantLanguageDetectionJobs =
147 yield* Comprehend.ListDominantLanguageDetectionJobs();
148 const listEntitiesDetectionJobs =
149 yield* Comprehend.ListEntitiesDetectionJobs();
150 const listEventsDetectionJobs = yield* Comprehend.ListEventsDetectionJobs();
151 const listKeyPhrasesDetectionJobs =
152 yield* Comprehend.ListKeyPhrasesDetectionJobs();
153 const listPiiEntitiesDetectionJobs =
154 yield* Comprehend.ListPiiEntitiesDetectionJobs();
155 const listSentimentDetectionJobs =
156 yield* Comprehend.ListSentimentDetectionJobs();
157 const listTargetedSentimentDetectionJobs =
158 yield* Comprehend.ListTargetedSentimentDetectionJobs();
159 const listTopicsDetectionJobs = yield* Comprehend.ListTopicsDetectionJobs();
160
161 const stopDominantLanguageDetectionJob =
162 yield* Comprehend.StopDominantLanguageDetectionJob();
163 const stopEntitiesDetectionJob =
164 yield* Comprehend.StopEntitiesDetectionJob();
165 const stopEventsDetectionJob = yield* Comprehend.StopEventsDetectionJob();
166 const stopKeyPhrasesDetectionJob =
167 yield* Comprehend.StopKeyPhrasesDetectionJob();
168 const stopPiiEntitiesDetectionJob =
169 yield* Comprehend.StopPiiEntitiesDetectionJob();
170 const stopSentimentDetectionJob =
171 yield* Comprehend.StopSentimentDetectionJob();
172 const stopTargetedSentimentDetectionJob =
173 yield* Comprehend.StopTargetedSentimentDetectionJob();
174
175 const PII_TEXT =
176 "My name is Jane Doe and my email address is jane@example.com.";
177 const REVIEW_TEXT = "I love this product, it works wonderfully!";
178 const BATCH_TEXTS = [
179 "I love this product, it works wonderfully!",
180 "The delivery was late and the box arrived damaged.",
181 ];
182
183 const jobInput = {
184 InputDataConfig: {
185 S3Uri: `s3://${FIXTURE_BUCKET_NAME}/comprehend-input/`,
186 InputFormat: "ONE_DOC_PER_LINE" as const,
187 },
188 OutputDataConfig: {
189 S3Uri: `s3://${FIXTURE_BUCKET_NAME}/comprehend-output/`,
190 },
191 };
192 const invalidJobInput = {
193 InputDataConfig: { S3Uri: INVALID_S3_URI },
194 OutputDataConfig: { S3Uri: INVALID_S3_URI },
195 };
196
197 return {
198 fetch: Effect.gen(function* () {
199 const request = yield* HttpServerRequest;
200 const url = new URL(request.originalUrl);
201 const pathname = url.pathname;
202
203 // Cheap readiness route — no Comprehend call.
204 if (request.method === "GET" && pathname === "/ping") {
205 return yield* HttpServerResponse.json({ ok: true });
206 }
207
208 // One route drives every single-document real-time binding.
209 if (request.method === "GET" && pathname === "/detect-all") {
210 const language = yield* detectDominantLanguage({
211 Text: "Bob ordered two sandwiches yesterday.",
212 });
213 const entities = yield* detectEntities({
214 Text: "Bob moved to Seattle in 2017.",
215 LanguageCode: "en",
216 });
217 const keyPhrases = yield* detectKeyPhrases({
218 Text: "The quarterly earnings report exceeded analyst expectations.",
219 LanguageCode: "en",
220 });
221 const pii = yield* detectPiiEntities({
222 Text: PII_TEXT,
223 LanguageCode: "en",
224 });
225 const sentiment = yield* detectSentiment({
226 Text: REVIEW_TEXT,
227 LanguageCode: "en",
228 });
229 const syntax = yield* detectSyntax({
230 Text: "The cat sat on the mat.",
231 LanguageCode: "en",
232 });
233 const targeted = yield* detectTargetedSentiment({
234 Text: "The screen is gorgeous but the battery is disappointing.",
235 LanguageCode: "en",
236 });
237 const toxic = yield* detectToxicContent({
238 TextSegments: [{ Text: "You are a wonderful person." }],
239 LanguageCode: "en",
240 });
241 const piiLabels = yield* containsPiiEntities({
242 Text: PII_TEXT,
243 LanguageCode: "en",
244 });
245 return yield* HttpServerResponse.json({
246 languageCode: language.Languages?.[0]?.LanguageCode,
247 entityTypes: (entities.Entities ?? []).map((e) => e.Type),
248 keyPhraseCount: (keyPhrases.KeyPhrases ?? []).length,
249 piiTypes: (pii.Entities ?? []).map((e) => e.Type),
250 sentiment: sentiment.Sentiment,
251 syntaxTags: (syntax.SyntaxTokens ?? []).map(
252 (t) => t.PartOfSpeech?.Tag,
253 ),
254 targetedEntityCount: (targeted.Entities ?? []).length,
255 toxicity: toxic.ResultList?.[0]?.Toxicity,
256 piiLabels: (piiLabels.Labels ?? []).map((l) => l.Name),
257 });
258 }
259
260 // One route drives every batch real-time binding.
261 if (request.method === "GET" && pathname === "/batch-all") {
262 const language = yield* batchDetectDominantLanguage({
263 TextList: BATCH_TEXTS,
264 });
265 const entities = yield* batchDetectEntities({
266 TextList: ["Bob moved to Seattle in 2017."],
267 LanguageCode: "en",
268 });
269 const keyPhrases = yield* batchDetectKeyPhrases({
270 TextList: BATCH_TEXTS,
271 LanguageCode: "en",
272 });
273 const sentiment = yield* batchDetectSentiment({
274 TextList: BATCH_TEXTS,
275 LanguageCode: "en",
276 });
277 const syntax = yield* batchDetectSyntax({
278 TextList: ["The cat sat on the mat."],
279 LanguageCode: "en",
280 });
281 const targeted = yield* batchDetectTargetedSentiment({
282 TextList: ["The screen is gorgeous but the battery is bad."],
283 LanguageCode: "en",
284 });
285 return yield* HttpServerResponse.json({
286 languageResults: (language.ResultList ?? []).length,
287 firstLanguage:
288 language.ResultList?.[0]?.Languages?.[0]?.LanguageCode,
289 entityResults: (entities.ResultList ?? []).length,
290 keyPhraseResults: (keyPhrases.ResultList ?? []).length,
291 sentimentResults: (sentiment.ResultList ?? []).length,
292 firstSentiment: sentiment.ResultList?.[0]?.Sentiment,
293 syntaxResults: (syntax.ResultList ?? []).length,
294 targetedResults: (targeted.ResultList ?? []).length,
295 });
296 }
297
298 // ClassifyDocument requires a trained custom endpoint; a well-formed
299 // same-account ARN that doesn't exist drives the typed
300 // ResourceUnavailableException path end-to-end (proving IAM + call
301 // plumbing without a multi-hour classifier training run).
302 if (request.method === "GET" && pathname === "/classify-document") {
303 const region = yield* Effect.sync(() => process.env.AWS_REGION);
304 const account = (yield* roleArn).split(":")[4];
305 const tag = yield* classifyDocument({
306 Text: "Subject: your invoice for March is attached",
307 EndpointArn: `arn:aws:comprehend:${region}:${account}:document-classifier-endpoint/alchemy-nonexistent`,
308 }).pipe(
309 Effect.map(() => "Classified"),
310 Effect.catchTag(
311 [
312 "ResourceUnavailableException",
313 "InvalidRequestException",
314 "NotAuthorizedException",
315 ],
316 (e) => Effect.succeed(e._tag),
317 ),
318 );
319 return yield* HttpServerResponse.json({ tag });
320 }
321
322 // One route drives all nine List*Jobs bindings.
323 if (request.method === "GET" && pathname === "/jobs/list-all") {
324 const counts = {
325 documentClassification: (
326 (yield* listDocumentClassificationJobs({ MaxResults: 5 }))
327 .DocumentClassificationJobPropertiesList ?? []
328 ).length,
329 dominantLanguage: (
330 (yield* listDominantLanguageDetectionJobs({ MaxResults: 5 }))
331 .DominantLanguageDetectionJobPropertiesList ?? []
332 ).length,
333 entities: (
334 (yield* listEntitiesDetectionJobs({ MaxResults: 5 }))
335 .EntitiesDetectionJobPropertiesList ?? []
336 ).length,
337 events: (
338 (yield* listEventsDetectionJobs({ MaxResults: 5 }))
339 .EventsDetectionJobPropertiesList ?? []
340 ).length,
341 keyPhrases: (
342 (yield* listKeyPhrasesDetectionJobs({ MaxResults: 5 }))
343 .KeyPhrasesDetectionJobPropertiesList ?? []
344 ).length,
345 piiEntities: (
346 (yield* listPiiEntitiesDetectionJobs({ MaxResults: 5 }))
347 .PiiEntitiesDetectionJobPropertiesList ?? []
348 ).length,
349 sentiment: (
350 (yield* listSentimentDetectionJobs({ MaxResults: 5 }))
351 .SentimentDetectionJobPropertiesList ?? []
352 ).length,
353 targetedSentiment: (
354 (yield* listTargetedSentimentDetectionJobs({ MaxResults: 5 }))
355 .TargetedSentimentDetectionJobPropertiesList ?? []
356 ).length,
357 topics: (
358 (yield* listTopicsDetectionJobs({ MaxResults: 5 }))
359 .TopicsDetectionJobPropertiesList ?? []
360 ).length,
361 };
362 return yield* HttpServerResponse.json(counts);
363 }
364
365 // One route drives all nine Describe*Job bindings through their
366 // typed JobNotFoundException path.
367 if (
368 request.method === "GET" &&
369 pathname === "/jobs/describe-not-found-all"
370 ) {
371 const probe = { JobId: BOGUS_JOB_ID };
372 const tags = {
373 documentClassification: yield* describeDocumentClassificationJob(
374 probe,
375 ).pipe(
376 Effect.map(() => "Found"),
377 Effect.catchTag("JobNotFoundException", (e) =>
378 Effect.succeed(e._tag),
379 ),
380 ),
381 dominantLanguage: yield* describeDominantLanguageDetectionJob(
382 probe,
383 ).pipe(
384 Effect.map(() => "Found"),
385 Effect.catchTag("JobNotFoundException", (e) =>
386 Effect.succeed(e._tag),
387 ),
388 ),
389 entities: yield* describeEntitiesDetectionJob(probe).pipe(
390 Effect.map(() => "Found"),
391 Effect.catchTag("JobNotFoundException", (e) =>
392 Effect.succeed(e._tag),
393 ),
394 ),
395 events: yield* describeEventsDetectionJob(probe).pipe(
396 Effect.map(() => "Found"),
397 Effect.catchTag(
398 ["JobNotFoundException", "NotAuthorizedException"],
399 (e) => Effect.succeed(e._tag),
400 ),
401 ),
402 keyPhrases: yield* describeKeyPhrasesDetectionJob(probe).pipe(
403 Effect.map(() => "Found"),
404 Effect.catchTag("JobNotFoundException", (e) =>
405 Effect.succeed(e._tag),
406 ),
407 ),
408 piiEntities: yield* describePiiEntitiesDetectionJob(probe).pipe(
409 Effect.map(() => "Found"),
410 Effect.catchTag("JobNotFoundException", (e) =>
411 Effect.succeed(e._tag),
412 ),
413 ),
414 sentiment: yield* describeSentimentDetectionJob(probe).pipe(
415 Effect.map(() => "Found"),
416 Effect.catchTag("JobNotFoundException", (e) =>
417 Effect.succeed(e._tag),
418 ),
419 ),
420 targetedSentiment: yield* describeTargetedSentimentDetectionJob(
421 probe,
422 ).pipe(
423 Effect.map(() => "Found"),
424 Effect.catchTag("JobNotFoundException", (e) =>
425 Effect.succeed(e._tag),
426 ),
427 ),
428 topics: yield* describeTopicsDetectionJob(probe).pipe(
429 Effect.map(() => "Found"),
430 Effect.catchTag("JobNotFoundException", (e) =>
431 Effect.succeed(e._tag),
432 ),
433 ),
434 };
435 return yield* HttpServerResponse.json(tags);
436 }
437
438 // One route drives all seven Stop*Job bindings through their typed
439 // JobNotFoundException path.
440 if (
441 request.method === "POST" &&
442 pathname === "/jobs/stop-not-found-all"
443 ) {
444 const probe = { JobId: BOGUS_JOB_ID };
445 const tags = {
446 dominantLanguage: yield* stopDominantLanguageDetectionJob(
447 probe,
448 ).pipe(
449 Effect.map(() => "Stopped"),
450 Effect.catchTag("JobNotFoundException", (e) =>
451 Effect.succeed(e._tag),
452 ),
453 ),
454 entities: yield* stopEntitiesDetectionJob(probe).pipe(
455 Effect.map(() => "Stopped"),
456 Effect.catchTag("JobNotFoundException", (e) =>
457 Effect.succeed(e._tag),
458 ),
459 ),
460 // Events detection is closed to new customers — the account
461 // surfaces the (patched) typed NotAuthorizedException instead of
462 // JobNotFoundException.
463 events: yield* stopEventsDetectionJob(probe).pipe(
464 Effect.map(() => "Stopped"),
465 Effect.catchTag(
466 ["JobNotFoundException", "NotAuthorizedException"],
467 (e) => Effect.succeed(e._tag),
468 ),
469 ),
470 keyPhrases: yield* stopKeyPhrasesDetectionJob(probe).pipe(
471 Effect.map(() => "Stopped"),
472 Effect.catchTag("JobNotFoundException", (e) =>
473 Effect.succeed(e._tag),
474 ),
475 ),
476 piiEntities: yield* stopPiiEntitiesDetectionJob(probe).pipe(
477 Effect.map(() => "Stopped"),
478 Effect.catchTag("JobNotFoundException", (e) =>
479 Effect.succeed(e._tag),
480 ),
481 ),
482 sentiment: yield* stopSentimentDetectionJob(probe).pipe(
483 Effect.map(() => "Stopped"),
484 Effect.catchTag("JobNotFoundException", (e) =>
485 Effect.succeed(e._tag),
486 ),
487 ),
488 targetedSentiment: yield* stopTargetedSentimentDetectionJob(
489 probe,
490 ).pipe(
491 Effect.map(() => "Stopped"),
492 Effect.catchTag("JobNotFoundException", (e) =>
493 Effect.succeed(e._tag),
494 ),
495 ),
496 };
497 return yield* HttpServerResponse.json(tags);
498 }
499
500 // Drives the eight non-sentiment Start*Job bindings through
501 // Comprehend's server-side ValidationException (invalid S3 URI) —
502 // a real runtime call proving role injection + IAM without paying
503 // for eight async jobs. Sentiment gets the real lifecycle below.
504 if (
505 request.method === "POST" &&
506 pathname === "/jobs/start-invalid-all"
507 ) {
508 const region = yield* Effect.sync(() => process.env.AWS_REGION);
509 const account = (yield* roleArn).split(":")[4];
510 const tags = {
511 documentClassification: yield* startDocumentClassificationJob({
512 ...invalidJobInput,
513 DocumentClassifierArn: `arn:aws:comprehend:${region}:${account}:document-classifier/alchemy-nonexistent`,
514 }).pipe(
515 Effect.map(() => "Started"),
516 Effect.catchTag(
517 [
518 "ValidationException",
519 "InvalidRequestException",
520 "ResourceNotFoundException",
521 ],
522 (e) => Effect.succeed(e._tag),
523 ),
524 ),
525 dominantLanguage: yield* startDominantLanguageDetectionJob(
526 invalidJobInput,
527 ).pipe(
528 Effect.map(() => "Started"),
529 Effect.catchTag(
530 ["ValidationException", "InvalidRequestException"],
531 (e) => Effect.succeed(e._tag),
532 ),
533 ),
534 entities: yield* startEntitiesDetectionJob({
535 ...invalidJobInput,
536 LanguageCode: "en",
537 }).pipe(
538 Effect.map(() => "Started"),
539 Effect.catchTag(
540 ["ValidationException", "InvalidRequestException"],
541 (e) => Effect.succeed(e._tag),
542 ),
543 ),
544 events: yield* startEventsDetectionJob({
545 ...invalidJobInput,
546 LanguageCode: "en",
547 TargetEventTypes: ["BANKRUPTCY"],
548 }).pipe(
549 Effect.map(() => "Started"),
550 Effect.catchTag(
551 [
552 "ValidationException",
553 "InvalidRequestException",
554 "NotAuthorizedException",
555 ],
556 (e) => Effect.succeed(e._tag),
557 ),
558 ),
559 keyPhrases: yield* startKeyPhrasesDetectionJob({
560 ...invalidJobInput,
561 LanguageCode: "en",
562 }).pipe(
563 Effect.map(() => "Started"),
564 Effect.catchTag(
565 ["ValidationException", "InvalidRequestException"],
566 (e) => Effect.succeed(e._tag),
567 ),
568 ),
569 piiEntities: yield* startPiiEntitiesDetectionJob({
570 ...invalidJobInput,
571 Mode: "ONLY_OFFSETS",
572 LanguageCode: "en",
573 }).pipe(
574 Effect.map(() => "Started"),
575 Effect.catchTag(
576 ["ValidationException", "InvalidRequestException"],
577 (e) => Effect.succeed(e._tag),
578 ),
579 ),
580 targetedSentiment: yield* startTargetedSentimentDetectionJob({
581 ...invalidJobInput,
582 LanguageCode: "en",
583 }).pipe(
584 Effect.map(() => "Started"),
585 Effect.catchTag(
586 ["ValidationException", "InvalidRequestException"],
587 (e) => Effect.succeed(e._tag),
588 ),
589 ),
590 topics: yield* startTopicsDetectionJob(invalidJobInput).pipe(
591 Effect.map(() => "Started"),
592 Effect.catchTag(
593 ["ValidationException", "InvalidRequestException"],
594 (e) => Effect.succeed(e._tag),
595 ),
596 ),
597 };
598 return yield* HttpServerResponse.json(tags);
599 }
600
601 // Full real lifecycle for the sentiment job family: seed the input
602 // object, start a real async job, describe it, then stop it.
603 if (
604 request.method === "POST" &&
605 pathname === "/jobs/sentiment/lifecycle"
606 ) {
607 yield* putObject({
608 Key: "comprehend-input/reviews.txt",
609 Body: "I love this product, it works wonderfully!\nThe delivery was late and the box arrived damaged.\n",
610 ContentType: "text/plain",
611 });
612 // A freshly created data-access role can take a few seconds to
613 // become assumable by Comprehend — surfaced as
614 // InvalidRequestException. Bounded retry (8 × 5s).
615 const started = yield* startSentimentDetectionJob({
616 ...jobInput,
617 JobName: "alchemy-comprehend-bindings-lifecycle",
618 LanguageCode: "en",
619 }).pipe(
620 Effect.retry({
621 while: (e): boolean => e._tag === "InvalidRequestException",
622 schedule: Schedule.spaced("5 seconds"),
623 times: 8,
624 }),
625 );
626 const jobId = started.JobId ?? "";
627 const described = yield* describeSentimentDetectionJob({
628 JobId: jobId,
629 });
630 const stopped = yield* stopSentimentDetectionJob({ JobId: jobId });
631 return yield* HttpServerResponse.json({
632 jobId,
633 startStatus: started.JobStatus,
634 describedStatus:
635 described.SentimentDetectionJobProperties?.JobStatus,
636 stopStatus: stopped.JobStatus,
637 });
638 }
639
640 return yield* HttpServerResponse.json(
641 { error: "Not found", method: request.method, pathname },
642 { status: 404 },
643 );
644 }).pipe(Effect.orDie),
645 };
646 }).pipe(
647 Effect.provide(
648 Layer.mergeAll(
649 S3.PutObjectHttp,
650 Comprehend.DetectDominantLanguageHttp,
651 Comprehend.DetectEntitiesHttp,
652 Comprehend.DetectKeyPhrasesHttp,
653 Comprehend.DetectPiiEntitiesHttp,
654 Comprehend.DetectSentimentHttp,
655 Comprehend.DetectSyntaxHttp,
656 Comprehend.DetectTargetedSentimentHttp,
657 Comprehend.DetectToxicContentHttp,
658 Comprehend.ContainsPiiEntitiesHttp,
659 Comprehend.ClassifyDocumentHttp,
660 Comprehend.BatchDetectDominantLanguageHttp,
661 Comprehend.BatchDetectEntitiesHttp,
662 Comprehend.BatchDetectKeyPhrasesHttp,
663 Comprehend.BatchDetectSentimentHttp,
664 Comprehend.BatchDetectSyntaxHttp,
665 Comprehend.BatchDetectTargetedSentimentHttp,
666 Comprehend.StartDocumentClassificationJobHttp,
667 Comprehend.StartDominantLanguageDetectionJobHttp,
668 Comprehend.StartEntitiesDetectionJobHttp,
669 Comprehend.StartEventsDetectionJobHttp,
670 Comprehend.StartKeyPhrasesDetectionJobHttp,
671 Comprehend.StartPiiEntitiesDetectionJobHttp,
672 Comprehend.StartSentimentDetectionJobHttp,
673 Comprehend.StartTargetedSentimentDetectionJobHttp,
674 Comprehend.StartTopicsDetectionJobHttp,
675 Comprehend.DescribeDocumentClassificationJobHttp,
676 Comprehend.DescribeDominantLanguageDetectionJobHttp,
677 Comprehend.DescribeEntitiesDetectionJobHttp,
678 Comprehend.DescribeEventsDetectionJobHttp,
679 Comprehend.DescribeKeyPhrasesDetectionJobHttp,
680 Comprehend.DescribePiiEntitiesDetectionJobHttp,
681 Comprehend.DescribeSentimentDetectionJobHttp,
682 Comprehend.DescribeTargetedSentimentDetectionJobHttp,
683 Comprehend.DescribeTopicsDetectionJobHttp,
684 Comprehend.ListDocumentClassificationJobsHttp,
685 Comprehend.ListDominantLanguageDetectionJobsHttp,
686 Comprehend.ListEntitiesDetectionJobsHttp,
687 Comprehend.ListEventsDetectionJobsHttp,
688 Comprehend.ListKeyPhrasesDetectionJobsHttp,
689 Comprehend.ListPiiEntitiesDetectionJobsHttp,
690 Comprehend.ListSentimentDetectionJobsHttp,
691 Comprehend.ListTargetedSentimentDetectionJobsHttp,
692 Comprehend.ListTopicsDetectionJobsHttp,
693 Comprehend.StopDominantLanguageDetectionJobHttp,
694 Comprehend.StopEntitiesDetectionJobHttp,
695 Comprehend.StopEventsDetectionJobHttp,
696 Comprehend.StopKeyPhrasesDetectionJobHttp,
697 Comprehend.StopPiiEntitiesDetectionJobHttp,
698 Comprehend.StopSentimentDetectionJobHttp,
699 Comprehend.StopTargetedSentimentDetectionJobHttp,
700 ),
701 ),
702 ),
703);