1import * as ComprehendMedical from "@/AWS/ComprehendMedical";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12// Well-formed (1-32 chars of [a-zA-Z0-9-]) but nonexistent job id — describe
13// and stop must answer with the typed ResourceNotFoundException.
14const FAKE_JOB_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
15
16export class ComprehendMedicalTestFunction extends Lambda.Function<Lambda.Function>()(
17 "ComprehendMedicalTestFunction",
18) {}
19
20export default ComprehendMedicalTestFunction.make(
21 {
22 main,
23 functionUrl: true,
24 // Comprehend Medical inference can exceed Lambda's 3s default under load.
25 timeout: Duration.seconds(30),
26 },
27 Effect.gen(function* () {
28 const detectEntities = yield* ComprehendMedical.DetectEntitiesV2();
29 const detectPHI = yield* ComprehendMedical.DetectPHI();
30 const inferICD10CM = yield* ComprehendMedical.InferICD10CM();
31 const inferRxNorm = yield* ComprehendMedical.InferRxNorm();
32 const inferSNOMEDCT = yield* ComprehendMedical.InferSNOMEDCT();
33
34 const listEntitiesJobs =
35 yield* ComprehendMedical.ListEntitiesDetectionV2Jobs();
36 const listICD10CMJobs = yield* ComprehendMedical.ListICD10CMInferenceJobs();
37 const listPHIJobs = yield* ComprehendMedical.ListPHIDetectionJobs();
38 const listRxNormJobs = yield* ComprehendMedical.ListRxNormInferenceJobs();
39 const listSNOMEDCTJobs =
40 yield* ComprehendMedical.ListSNOMEDCTInferenceJobs();
41
42 const describeEntitiesJob =
43 yield* ComprehendMedical.DescribeEntitiesDetectionV2Job();
44 const describeICD10CMJob =
45 yield* ComprehendMedical.DescribeICD10CMInferenceJob();
46 const describePHIJob = yield* ComprehendMedical.DescribePHIDetectionJob();
47 const describeRxNormJob =
48 yield* ComprehendMedical.DescribeRxNormInferenceJob();
49 const describeSNOMEDCTJob =
50 yield* ComprehendMedical.DescribeSNOMEDCTInferenceJob();
51
52 const stopEntitiesJob =
53 yield* ComprehendMedical.StopEntitiesDetectionV2Job();
54 const stopICD10CMJob = yield* ComprehendMedical.StopICD10CMInferenceJob();
55 const stopPHIJob = yield* ComprehendMedical.StopPHIDetectionJob();
56 const stopRxNormJob = yield* ComprehendMedical.StopRxNormInferenceJob();
57 const stopSNOMEDCTJob = yield* ComprehendMedical.StopSNOMEDCTInferenceJob();
58
59 const startEntitiesJob =
60 yield* ComprehendMedical.StartEntitiesDetectionV2Job();
61 const startICD10CMJob = yield* ComprehendMedical.StartICD10CMInferenceJob();
62 const startPHIJob = yield* ComprehendMedical.StartPHIDetectionJob();
63 const startRxNormJob = yield* ComprehendMedical.StartRxNormInferenceJob();
64 const startSNOMEDCTJob =
65 yield* ComprehendMedical.StartSNOMEDCTInferenceJob();
66 // The remaining start bindings attach their IAM statements at deploy
67 // time; the PHI start callable is the one exercised at runtime.
68 void startEntitiesJob;
69 void startICD10CMJob;
70 void startRxNormJob;
71 void startSNOMEDCTJob;
72
73 return {
74 fetch: Effect.gen(function* () {
75 const request = yield* HttpServerRequest;
76 const url = new URL(request.originalUrl);
77 const pathname = url.pathname;
78
79 // Cheap readiness route — no Comprehend Medical call.
80 if (request.method === "GET" && pathname === "/ping") {
81 return yield* HttpServerResponse.json({ ok: true });
82 }
83
84 if (request.method === "GET" && pathname === "/entities") {
85 const result = yield* detectEntities({
86 Text: "Patient takes 50 mg atenolol daily for hypertension.",
87 });
88 return yield* HttpServerResponse.json({
89 entities: (result.Entities ?? []).map((entity) => ({
90 text: entity.Text,
91 category: entity.Category,
92 type: entity.Type,
93 })),
94 modelVersion: result.ModelVersion,
95 });
96 }
97
98 if (request.method === "GET" && pathname === "/phi") {
99 const result = yield* detectPHI({
100 Text: "John Doe, age 47, was seen on 2024-01-03 at Seattle Clinic.",
101 });
102 return yield* HttpServerResponse.json({
103 entities: (result.Entities ?? []).map((entity) => ({
104 text: entity.Text,
105 type: entity.Type,
106 })),
107 });
108 }
109
110 if (request.method === "GET" && pathname === "/icd10") {
111 const result = yield* inferICD10CM({
112 Text: "Patient presents with type 2 diabetes and hypertension.",
113 });
114 return yield* HttpServerResponse.json({
115 entities: (result.Entities ?? []).map((entity) => ({
116 text: entity.Text,
117 codes: (entity.ICD10CMConcepts ?? []).map((concept) => ({
118 code: concept.Code,
119 description: concept.Description,
120 })),
121 })),
122 });
123 }
124
125 if (request.method === "GET" && pathname === "/rxnorm") {
126 const result = yield* inferRxNorm({
127 Text: "Patient takes 50 mg atenolol daily and 500 mg metformin twice a day.",
128 });
129 return yield* HttpServerResponse.json({
130 entities: (result.Entities ?? []).map((entity) => ({
131 text: entity.Text,
132 concepts: (entity.RxNormConcepts ?? []).map((concept) => ({
133 code: concept.Code,
134 description: concept.Description,
135 })),
136 })),
137 });
138 }
139
140 if (request.method === "GET" && pathname === "/snomed") {
141 const result = yield* inferSNOMEDCT({
142 Text: "Patient presents with type 2 diabetes and hypertension.",
143 });
144 return yield* HttpServerResponse.json({
145 entities: (result.Entities ?? []).map((entity) => ({
146 text: entity.Text,
147 concepts: (entity.SNOMEDCTConcepts ?? []).map((concept) => ({
148 code: concept.Code,
149 description: concept.Description,
150 })),
151 })),
152 });
153 }
154
155 // Exercises the five List* bindings — real calls, no side effects.
156 if (request.method === "GET" && pathname === "/jobs") {
157 const [entities, icd10cm, phi, rxnorm, snomedct] = yield* Effect.all(
158 [
159 listEntitiesJobs({}),
160 listICD10CMJobs({}),
161 listPHIJobs({}),
162 listRxNormJobs({}),
163 listSNOMEDCTJobs({}),
164 ],
165 { concurrency: 5 },
166 );
167 return yield* HttpServerResponse.json({
168 entities:
169 entities.ComprehendMedicalAsyncJobPropertiesList?.length ?? 0,
170 icd10cm:
171 icd10cm.ComprehendMedicalAsyncJobPropertiesList?.length ?? 0,
172 phi: phi.ComprehendMedicalAsyncJobPropertiesList?.length ?? 0,
173 rxnorm: rxnorm.ComprehendMedicalAsyncJobPropertiesList?.length ?? 0,
174 snomedct:
175 snomedct.ComprehendMedicalAsyncJobPropertiesList?.length ?? 0,
176 });
177 }
178
179 // Exercises the Describe*/Stop* bindings against a nonexistent job:
180 // IAM authorizes first, so the typed ResourceNotFoundException proves
181 // both the wiring and the granted action.
182 if (request.method === "GET" && pathname === "/job-checks") {
183 const success = () => "Success" as string;
184 const tagOf = (e: { readonly _tag: string }) =>
185 Effect.succeed(e._tag as string);
186
187 const [describes, stops] = yield* Effect.all([
188 Effect.all(
189 [
190 describeEntitiesJob({ JobId: FAKE_JOB_ID }).pipe(
191 Effect.map(success),
192 Effect.catchTag("ResourceNotFoundException", tagOf),
193 ),
194 describeICD10CMJob({ JobId: FAKE_JOB_ID }).pipe(
195 Effect.map(success),
196 Effect.catchTag("ResourceNotFoundException", tagOf),
197 ),
198 describePHIJob({ JobId: FAKE_JOB_ID }).pipe(
199 Effect.map(success),
200 Effect.catchTag("ResourceNotFoundException", tagOf),
201 ),
202 describeRxNormJob({ JobId: FAKE_JOB_ID }).pipe(
203 Effect.map(success),
204 Effect.catchTag("ResourceNotFoundException", tagOf),
205 ),
206 describeSNOMEDCTJob({ JobId: FAKE_JOB_ID }).pipe(
207 Effect.map(success),
208 Effect.catchTag("ResourceNotFoundException", tagOf),
209 ),
210 ],
211 { concurrency: 5 },
212 ),
213 Effect.all(
214 [
215 stopEntitiesJob({ JobId: FAKE_JOB_ID }).pipe(
216 Effect.map(success),
217 Effect.catchTag("ResourceNotFoundException", tagOf),
218 ),
219 stopICD10CMJob({ JobId: FAKE_JOB_ID }).pipe(
220 Effect.map(success),
221 Effect.catchTag("ResourceNotFoundException", tagOf),
222 ),
223 stopPHIJob({ JobId: FAKE_JOB_ID }).pipe(
224 Effect.map(success),
225 Effect.catchTag("ResourceNotFoundException", tagOf),
226 ),
227 stopRxNormJob({ JobId: FAKE_JOB_ID }).pipe(
228 Effect.map(success),
229 Effect.catchTag("ResourceNotFoundException", tagOf),
230 ),
231 stopSNOMEDCTJob({ JobId: FAKE_JOB_ID }).pipe(
232 Effect.map(success),
233 Effect.catchTag("ResourceNotFoundException", tagOf),
234 ),
235 ],
236 { concurrency: 5 },
237 ),
238 ]);
239 return yield* HttpServerResponse.json({ describes, stops });
240 }
241
242 // Exercises a Start* binding: the request passes the
243 // comprehendmedical:StartPHIDetectionJob IAM check and fails typed on
244 // the same-account-but-nonexistent data-access role supplied by the
245 // test (DATA_ACCESS_ROLE_ARN_INVALID). An unauthorized caller would
246 // get AccessDeniedException instead — so InvalidRequestException
247 // proves the binding granted the Start action.
248 if (request.method === "GET" && pathname === "/job-start") {
249 const roleArn = url.searchParams.get("roleArn");
250 if (!roleArn) {
251 return yield* HttpServerResponse.json(
252 { error: "roleArn query parameter required" },
253 { status: 400 },
254 );
255 }
256 const outcome = yield* startPHIJob({
257 InputDataConfig: {
258 S3Bucket: "alchemy-nonexistent-input-bucket",
259 S3Key: "notes/",
260 },
261 OutputDataConfig: {
262 S3Bucket: "alchemy-nonexistent-output-bucket",
263 },
264 DataAccessRoleArn: roleArn,
265 LanguageCode: "en",
266 }).pipe(
267 Effect.map(() => ({ tag: "Success" })),
268 Effect.catchTag(
269 [
270 "AccessDeniedException",
271 "InvalidRequestException",
272 "ResourceNotFoundException",
273 ],
274 (e) => Effect.succeed({ tag: e._tag as string }),
275 ),
276 );
277 return yield* HttpServerResponse.json(outcome);
278 }
279
280 return yield* HttpServerResponse.json(
281 { error: "Not found", method: request.method, pathname },
282 { status: 404 },
283 );
284 }).pipe(Effect.orDie),
285 };
286 }).pipe(
287 Effect.provide(
288 Layer.mergeAll(
289 ComprehendMedical.DetectEntitiesV2Http,
290 ComprehendMedical.DetectPHIHttp,
291 ComprehendMedical.InferICD10CMHttp,
292 ComprehendMedical.InferRxNormHttp,
293 ComprehendMedical.InferSNOMEDCTHttp,
294 ComprehendMedical.ListEntitiesDetectionV2JobsHttp,
295 ComprehendMedical.ListICD10CMInferenceJobsHttp,
296 ComprehendMedical.ListPHIDetectionJobsHttp,
297 ComprehendMedical.ListRxNormInferenceJobsHttp,
298 ComprehendMedical.ListSNOMEDCTInferenceJobsHttp,
299 ComprehendMedical.DescribeEntitiesDetectionV2JobHttp,
300 ComprehendMedical.DescribeICD10CMInferenceJobHttp,
301 ComprehendMedical.DescribePHIDetectionJobHttp,
302 ComprehendMedical.DescribeRxNormInferenceJobHttp,
303 ComprehendMedical.DescribeSNOMEDCTInferenceJobHttp,
304 ComprehendMedical.StopEntitiesDetectionV2JobHttp,
305 ComprehendMedical.StopICD10CMInferenceJobHttp,
306 ComprehendMedical.StopPHIDetectionJobHttp,
307 ComprehendMedical.StopRxNormInferenceJobHttp,
308 ComprehendMedical.StopSNOMEDCTInferenceJobHttp,
309 ComprehendMedical.StartEntitiesDetectionV2JobHttp,
310 ComprehendMedical.StartICD10CMInferenceJobHttp,
311 ComprehendMedical.StartPHIDetectionJobHttp,
312 ComprehendMedical.StartRxNormInferenceJobHttp,
313 ComprehendMedical.StartSNOMEDCTInferenceJobHttp,
314 ),
315 ),
316 ),
317);