@inflexa-ai/harness 0.30.2 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist/contracts/target-dossier.d.ts +61 -61
  2. package/dist/execution/run-synthesis.js +7 -2
  3. package/dist/lib/zod-issues.d.ts +8 -0
  4. package/dist/lib/zod-issues.js +13 -0
  5. package/dist/literature/sources/semantic-scholar.d.ts +34 -0
  6. package/dist/literature/sources/semantic-scholar.js +15 -4
  7. package/dist/sandbox/cpu-files.d.ts +37 -0
  8. package/dist/sandbox/cpu-files.js +55 -0
  9. package/dist/sandbox/docker-client.d.ts +15 -0
  10. package/dist/sandbox/docker-client.js +100 -11
  11. package/dist/sandbox/k8s-client.d.ts +5 -0
  12. package/dist/sandbox/k8s-client.js +128 -19
  13. package/dist/sandbox/thread-env.d.ts +29 -0
  14. package/dist/sandbox/thread-env.js +49 -0
  15. package/dist/state/data-profile.d.ts +35 -0
  16. package/dist/state/data-profile.js +56 -0
  17. package/dist/state/index.d.ts +1 -1
  18. package/dist/state/index.js +1 -1
  19. package/dist/tasks/data-profile.d.ts +16 -1
  20. package/dist/tasks/data-profile.js +31 -10
  21. package/dist/tools/bio/chembl.js +4 -3
  22. package/dist/tools/bio/drug-gene-interactions.d.ts +2 -14
  23. package/dist/tools/bio/drug-gene-interactions.js +10 -18
  24. package/dist/tools/bio/gene-disease-evidence.d.ts +3 -1
  25. package/dist/tools/bio/gene-disease-evidence.js +12 -9
  26. package/dist/tools/bio/keys.js +1 -0
  27. package/dist/tools/bio/lookup-annotation.d.ts +43 -0
  28. package/dist/tools/bio/lookup-annotation.js +19 -4
  29. package/dist/tools/bio/pubchem.js +5 -3
  30. package/dist/tools/bio/search-clinical-trials.js +5 -4
  31. package/dist/tools/bio/search-geo-datasets.d.ts +39 -9
  32. package/dist/tools/bio/search-geo-datasets.js +38 -20
  33. package/dist/tools/lib/api-utils.d.ts +27 -2
  34. package/dist/tools/lib/api-utils.js +43 -3
  35. package/dist/tools/lib/bgee-client.d.ts +16 -0
  36. package/dist/tools/lib/bgee-client.js +16 -5
  37. package/dist/tools/lib/cbioportal-client.d.ts +20 -0
  38. package/dist/tools/lib/cbioportal-client.js +16 -8
  39. package/dist/tools/lib/chembl-client.d.ts +105 -3
  40. package/dist/tools/lib/chembl-client.js +196 -84
  41. package/dist/tools/lib/clinical-benchmarks-client.d.ts +5 -0
  42. package/dist/tools/lib/clinical-benchmarks-client.js +5 -0
  43. package/dist/tools/lib/clinical-trials-client.d.ts +131 -2
  44. package/dist/tools/lib/clinical-trials-client.js +32 -6
  45. package/dist/tools/lib/clinvar-client.d.ts +35 -0
  46. package/dist/tools/lib/clinvar-client.js +34 -28
  47. package/dist/tools/lib/ctx-ops.d.ts +106 -35
  48. package/dist/tools/lib/ctx-ops.js +135 -69
  49. package/dist/tools/lib/dgidb-client.d.ts +73 -0
  50. package/dist/tools/lib/dgidb-client.js +25 -13
  51. package/dist/tools/lib/disgenet-client.d.ts +117 -24
  52. package/dist/tools/lib/disgenet-client.js +127 -34
  53. package/dist/tools/lib/disgenet-config.d.ts +1 -1
  54. package/dist/tools/lib/disgenet-config.js +5 -3
  55. package/dist/tools/lib/drugbank-client.d.ts +57 -58
  56. package/dist/tools/lib/drugbank-client.js +69 -64
  57. package/dist/tools/lib/drugbank-config.d.ts +6 -1
  58. package/dist/tools/lib/drugbank-config.js +6 -1
  59. package/dist/tools/lib/ema-client.d.ts +53 -0
  60. package/dist/tools/lib/ema-client.js +19 -7
  61. package/dist/tools/lib/ensembl-client.d.ts +4 -0
  62. package/dist/tools/lib/ensembl-client.js +4 -0
  63. package/dist/tools/lib/gwas-catalog-client.d.ts +64 -1
  64. package/dist/tools/lib/gwas-catalog-client.js +21 -6
  65. package/dist/tools/lib/identifier-resolver.d.ts +17 -1
  66. package/dist/tools/lib/identifier-resolver.js +13 -4
  67. package/dist/tools/lib/impc-client.d.ts +4 -0
  68. package/dist/tools/lib/impc-client.js +4 -0
  69. package/dist/tools/lib/iuphar-client.d.ts +10 -5
  70. package/dist/tools/lib/iuphar-client.js +49 -13
  71. package/dist/tools/lib/monarch-client.d.ts +4 -0
  72. package/dist/tools/lib/monarch-client.js +4 -0
  73. package/dist/tools/lib/openfda-client.d.ts +34 -0
  74. package/dist/tools/lib/openfda-client.js +22 -8
  75. package/dist/tools/lib/opentargets-client.d.ts +99 -4
  76. package/dist/tools/lib/opentargets-client.js +97 -37
  77. package/dist/tools/lib/pathway-client.d.ts +45 -0
  78. package/dist/tools/lib/pathway-client.js +88 -8
  79. package/dist/tools/lib/pharmgkb-client.d.ts +35 -1
  80. package/dist/tools/lib/pharmgkb-client.js +22 -6
  81. package/dist/tools/lib/pharmgkb-config.d.ts +8 -1
  82. package/dist/tools/lib/pharmgkb-config.js +8 -1
  83. package/dist/tools/lib/pubchem-client.d.ts +22 -0
  84. package/dist/tools/lib/pubchem-client.js +36 -33
  85. package/dist/tools/lib/pubchem-ops.d.ts +85 -5
  86. package/dist/tools/lib/pubchem-ops.js +96 -68
  87. package/dist/tools/lib/pubmed-client.d.ts +4 -0
  88. package/dist/tools/lib/pubmed-client.js +4 -0
  89. package/dist/tools/lib/string-client.d.ts +52 -0
  90. package/dist/tools/lib/string-client.js +12 -7
  91. package/dist/tools/lib/uniprot-client.d.ts +4 -0
  92. package/dist/tools/lib/uniprot-client.js +4 -0
  93. package/dist/tools/research/context7-docs.d.ts +23 -0
  94. package/dist/tools/research/context7-docs.js +49 -9
  95. package/dist/tools/research/search-github-repos.d.ts +4 -0
  96. package/dist/tools/research/search-github-repos.js +4 -0
  97. package/dist/workflows/execute-target-assessment.js +4 -4
  98. package/dist/workflows/target-assessment/assemblers/orchestrator.d.ts +2 -1
  99. package/dist/workflows/target-assessment/assemblers/orchestrator.js +3 -3
  100. package/dist/workflows/target-assessment/collectors/index.d.ts +8 -2
  101. package/dist/workflows/target-assessment/collectors/index.js +37 -10
  102. package/dist/workflows/target-assessment/fanout/index.d.ts +2 -1
  103. package/dist/workflows/target-assessment/fanout/index.js +2 -2
  104. package/dist/workflows/target-assessment/lib/safety-corroboration.js +2 -2
  105. package/dist/workflows/target-assessment/lib/target-identity-filter.d.ts +3 -2
  106. package/dist/workflows/target-assessment/lib/target-identity-filter.js +16 -5
  107. package/dist/workflows/target-assessment/phase4-assemble.d.ts +61 -60
  108. package/dist/workflows/target-assessment/phase4-assemble.js +2 -2
  109. package/dist/workflows/target-assessment/schemas.d.ts +2 -8
  110. package/dist/workflows/target-assessment/schemas.js +8 -2
  111. package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +1 -4
  112. package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -4
  113. package/package.json +1 -1
  114. package/dist/agents/sandbox/__fixtures__/deps.d.ts +0 -23
  115. package/dist/agents/sandbox/__fixtures__/deps.js +0 -102
  116. package/dist/citations/__fixtures__/resolver.d.ts +0 -3
  117. package/dist/citations/__fixtures__/resolver.js +0 -9
  118. package/dist/loop/__fixtures__/scripted-provider.d.ts +0 -31
  119. package/dist/loop/__fixtures__/scripted-provider.js +0 -81
  120. package/dist/providers/__fixtures__/session.d.ts +0 -17
  121. package/dist/providers/__fixtures__/session.js +0 -20
  122. package/dist/tools/__fixtures__/tool-context.d.ts +0 -13
  123. package/dist/tools/__fixtures__/tool-context.js +0 -22
@@ -2,10 +2,32 @@
2
2
  * Pure async client functions for the ChEMBL REST API.
3
3
  *
4
4
  * Used directly by target-assessment workflow steps and by tool wrappers.
5
+ *
6
+ * Absence policy: ChEMBL sends an explicit `null` for an absent value, and it
7
+ * never omits the key. Thus a maybe-absent field carries `.nullable()`, not
8
+ * `.optional()`.
5
9
  */
6
10
  import { z } from "zod";
7
- import { apiFetchValidated, describeApiError } from "./api-utils.js";
11
+ import { createNoopLogger } from "../../lib/console-logger.js";
12
+ import { apiFetchValidated, describeApiError, isUnexpectedApiError, zWireNumber } from "./api-utils.js";
8
13
  import { CHEMBL_BASE, CHEMBL_HEADERS as HEADERS } from "./chembl-config.js";
14
+ /** The `max_phase` of an approved drug. ChEMBL serializes it as the string "4.0". */
15
+ const APPROVED_MAX_PHASE = 4;
16
+ /**
17
+ * Report one molecule that a per-molecule lookup dropped from its result.
18
+ *
19
+ * These loops turned every error into a skipped molecule, thus a ChEMBL contract
20
+ * break emptied a whole modulator list in silence. An unexpected cause now
21
+ * reaches the injected `Logger` before the loop degrades. An expected miss, such
22
+ * as an HTTP 404, stays silent, because ChEMBL holds no record of that molecule.
23
+ */
24
+ function reportSkippedMolecule(logger, error, moleculeChemblId) {
25
+ if (!isUnexpectedApiError(error))
26
+ return;
27
+ logger.error("molecule dropped from the result", { moleculeChemblId, cause: describeApiError(error) });
28
+ }
29
+ /** How many disease terms one drug can contribute to an indication read. */
30
+ const INDICATIONS_PER_DRUG = 20;
9
31
  const TargetComponentSynonymSchema = z.object({
10
32
  syn_type: z.string().optional(),
11
33
  component_synonym: z.string().optional(),
@@ -18,54 +40,59 @@ const RawTargetSchema = z.object({
18
40
  target_chembl_id: z.string().optional(),
19
41
  pref_name: z.string().optional(),
20
42
  target_type: z.string().optional(),
21
- organism: z.string().optional(),
43
+ organism: z.string().nullable().optional(),
22
44
  target_components: z.array(TargetComponentSchema).optional(),
23
45
  });
46
+ // ChEMBL serves a Tastypie `decimal` as a JSON string ("41.0"), and it encodes an
47
+ // absent value as an explicit `null` rather than an omitted key. Thus a decimal
48
+ // field reads through `zWireNumber`, and a field that the published schema marks
49
+ // nullable carries `.nullable()`.
24
50
  /** A raw activity row from the ChEMBL `activity` endpoint. */
25
51
  const RawChemblActivitySchema = z.object({
26
52
  activity_id: z.number().optional(),
27
53
  molecule_chembl_id: z.string().optional(),
28
54
  target_chembl_id: z.string().optional(),
29
55
  standard_type: z.string().optional(),
30
- standard_value: z.union([z.string(), z.number()]).nullable().optional(),
31
- standard_units: z.string().optional(),
56
+ standard_value: zWireNumber.nullable().optional(),
57
+ standard_units: z.string().nullable().optional(),
32
58
  assay_chembl_id: z.string().optional(),
33
59
  assay_type: z.string().optional(),
34
- pchembl_value: z.union([z.string(), z.number()]).nullable().optional(),
60
+ pchembl_value: zWireNumber.nullable().optional(),
35
61
  });
36
62
  /** A raw mechanism row from the ChEMBL `mechanism` endpoint. */
37
63
  const RawChemblMechanismSchema = z.object({
38
64
  mechanism_of_action: z.string().optional(),
39
- action_type: z.string().optional(),
40
- target_chembl_id: z.string().optional(),
65
+ action_type: z.string().nullable().optional(),
66
+ target_chembl_id: z.string().nullable().optional(),
41
67
  molecule_chembl_id: z.string().optional(),
42
68
  });
43
- /** A drug-indication record nested inside a ChEMBL molecule/drug record. */
44
- const DrugIndicationRecordSchema = z.object({
69
+ /** A raw row of the ChEMBL `drug_indication` resource, which pairs a molecule with a disease term. */
70
+ const RawDrugIndicationSchema = z.object({
71
+ molecule_chembl_id: z.string().nullable().optional(),
45
72
  mesh_heading: z.string().optional(),
46
- efo_term: z.string().optional(),
73
+ efo_term: z.string().nullable().optional(),
74
+ max_phase_for_ind: zWireNumber.nullable().optional(),
47
75
  });
48
- /** A raw molecule/drug record from the ChEMBL `molecule` / `drug` endpoints. */
76
+ /** A raw molecule record from the ChEMBL `molecule` endpoint. */
49
77
  const RawChemblMoleculeSchema = z.object({
50
78
  molecule_chembl_id: z.string().optional(),
51
- pref_name: z.string().optional(),
52
- max_phase: z.number().nullable().optional(),
53
- molecule_type: z.string().optional(),
79
+ pref_name: z.string().nullable().optional(),
80
+ max_phase: zWireNumber.nullable().optional(),
81
+ molecule_type: z.string().nullable().optional(),
54
82
  first_approval: z.number().nullable().optional(),
55
- drug_indications: z.array(DrugIndicationRecordSchema).optional(),
56
83
  molecule_hierarchy: z.object({ parent_chembl_id: z.string().optional() }).optional(),
57
84
  });
58
85
  // Response envelopes returned by the ChEMBL list/record endpoints, validated at
59
86
  // the fetch boundary. Every wrapper field is optional because the API omits it
60
87
  // when there is no data (a 404 is handled separately as an expected empty).
61
- const TargetSearchResponseSchema = z.object({ targets: z.array(RawTargetSchema).optional() });
62
- const ActivitiesResponseSchema = z.object({ activities: z.array(RawChemblActivitySchema).optional() });
63
- const MechanismsResponseSchema = z.object({ mechanisms: z.array(RawChemblMechanismSchema).optional() });
64
- const DrugSearchResponseSchema = z.object({ drugs: z.array(RawChemblMoleculeSchema).optional() });
65
- const MoleculeSearchResponseSchema = z.object({ molecules: z.array(RawChemblMoleculeSchema).optional() });
88
+ export const TargetSearchResponseSchema = z.object({ targets: z.array(RawTargetSchema).optional() });
89
+ export const ActivitiesResponseSchema = z.object({ activities: z.array(RawChemblActivitySchema).optional() });
90
+ export const MechanismsResponseSchema = z.object({ mechanisms: z.array(RawChemblMechanismSchema).optional() });
91
+ export const DrugIndicationsResponseSchema = z.object({ drug_indications: z.array(RawDrugIndicationSchema).optional() });
92
+ export const MoleculeSearchResponseSchema = z.object({ molecules: z.array(RawChemblMoleculeSchema).optional() });
66
93
  const TargetNameResponseSchema = z.object({ pref_name: z.string().optional() });
67
- const MoleculeStructuresResponseSchema = z.object({
68
- molecule_structures: z.object({ standard_inchi_key: z.string().nullable().optional() }).optional(),
94
+ export const MoleculeStructuresResponseSchema = z.object({
95
+ molecule_structures: z.object({ standard_inchi_key: z.string().nullable().optional() }).nullable().optional(),
69
96
  });
70
97
  const TargetComponentsResponseSchema = z.object({
71
98
  target_components: z.array(z.object({ accession: z.string().optional(), component_type: z.string().optional() })).optional(),
@@ -90,9 +117,11 @@ const CompoundRecordSchema = z
90
117
  .optional(),
91
118
  molecule_properties: z
92
119
  .object({
93
- full_mwt: z.string().nullable().optional(),
94
- alogp: z.string().nullable().optional(),
95
- molecular_formula: z.string().nullable().optional(),
120
+ full_mwt: zWireNumber.nullable().optional(),
121
+ alogp: zWireNumber.nullable().optional(),
122
+ // `full_molformula` is the wire name of the molecular formula. The
123
+ // `molecule_properties` block carries no `molecular_formula` key.
124
+ full_molformula: z.string().nullable().optional(),
96
125
  })
97
126
  .nullable()
98
127
  .optional(),
@@ -101,11 +130,11 @@ const CompoundRecordSchema = z
101
130
  chemblId: raw.molecule_chembl_id ?? "",
102
131
  preferredCompoundName: raw.pref_name ?? null,
103
132
  canonicalSmiles: raw.molecule_structures?.canonical_smiles ?? null,
104
- molecularWeight: raw.molecule_properties?.full_mwt ? parseFloat(raw.molecule_properties.full_mwt) : null,
105
- alogp: raw.molecule_properties?.alogp ? parseFloat(raw.molecule_properties.alogp) : null,
106
- molecularFormula: raw.molecule_properties?.molecular_formula ?? null,
133
+ molecularWeight: raw.molecule_properties?.full_mwt ?? null,
134
+ alogp: raw.molecule_properties?.alogp ?? null,
135
+ molecularFormula: raw.molecule_properties?.full_molformula ?? null,
107
136
  }));
108
- const CompoundsResponseSchema = z.object({ molecules: z.array(CompoundRecordSchema).optional() });
137
+ export const CompoundsResponseSchema = z.object({ molecules: z.array(CompoundRecordSchema).optional() });
109
138
  // The two hops of the target-mode compound search read one id each, so they
110
139
  // project the response down to that id rather than reusing the richer
111
140
  // target/activity schemas above.
@@ -139,12 +168,6 @@ function mapTarget(raw) {
139
168
  geneNames: extractGeneNames(raw.target_components),
140
169
  };
141
170
  }
142
- function parseNumeric(val) {
143
- if (val == null)
144
- return null;
145
- const n = typeof val === "number" ? val : parseFloat(val);
146
- return Number.isNaN(n) ? null : n;
147
- }
148
171
  /**
149
172
  * Compounds assayed against a target: resolve the query to a target ChEMBL ID,
150
173
  * read that target's activity rows, then fetch the unique molecules behind them.
@@ -264,11 +287,11 @@ export async function getBioactivity(chemblId, type, options = {}) {
264
287
  compoundChemblId: raw.molecule_chembl_id ?? "",
265
288
  targetChemblId: raw.target_chembl_id ?? null,
266
289
  standardType: raw.standard_type ?? null,
267
- standardValue: parseNumeric(raw.standard_value),
290
+ standardValue: raw.standard_value ?? null,
268
291
  standardUnits: raw.standard_units ?? null,
269
292
  assayChemblId: raw.assay_chembl_id ?? null,
270
293
  assayType: raw.assay_type ?? null,
271
- pchemblValue: parseNumeric(raw.pchembl_value),
294
+ pchemblValue: raw.pchembl_value ?? null,
272
295
  }));
273
296
  }
274
297
  async function fetchTargetName(targetChemblId) {
@@ -311,56 +334,137 @@ export async function getMechanism(chemblId) {
311
334
  moleculeChemblId: raw.molecule_chembl_id ?? "",
312
335
  }));
313
336
  }
314
- function extractIndication(indications) {
315
- if (!indications?.length)
316
- return null;
337
+ function mapDrug(raw, indication) {
338
+ return {
339
+ moleculeChemblId: raw.molecule_chembl_id ?? "",
340
+ preferredName: raw.pref_name ?? null,
341
+ maxPhase: raw.max_phase ?? null,
342
+ moleculeType: raw.molecule_type ?? null,
343
+ firstApproval: raw.first_approval ?? null,
344
+ indication,
345
+ };
346
+ }
347
+ /** Join the disease terms of one molecule. The MeSH heading is the curated name, thus it comes first. */
348
+ function joinIndicationTerms(rows) {
317
349
  const terms = new Set();
318
- for (const ind of indications) {
319
- if (ind.mesh_heading)
320
- terms.add(ind.mesh_heading);
321
- else if (ind.efo_term)
322
- terms.add(ind.efo_term);
350
+ for (const row of rows) {
351
+ const term = row.mesh_heading ?? row.efo_term;
352
+ if (term)
353
+ terms.add(term);
323
354
  }
324
355
  return terms.size > 0 ? [...terms].join("; ") : null;
325
356
  }
326
- export async function getDrugInfo(query, limit = 25) {
327
- const drugUrl = `${CHEMBL_BASE}/drug/search.json?q=${encodeURIComponent(query)}&limit=${limit}`;
328
- const drugRes = await apiFetchValidated(drugUrl, DrugSearchResponseSchema, { headers: HEADERS });
329
- if (drugRes.isOk() && drugRes.value.drugs?.length) {
330
- return drugRes.value.drugs.map((raw) => ({
331
- moleculeChemblId: raw.molecule_chembl_id ?? "",
332
- preferredName: raw.pref_name ?? null,
333
- maxPhase: raw.max_phase != null ? Number(raw.max_phase) : null,
334
- moleculeType: raw.molecule_type ?? null,
335
- firstApproval: raw.first_approval ?? null,
336
- indication: extractIndication(raw.drug_indications),
337
- }));
357
+ /**
358
+ * Read the `drug_indication` resource under one filter.
359
+ *
360
+ * The rows come highest-phase first, thus an approved indication survives the
361
+ * `limit` of a disease that hundreds of molecules carry.
362
+ */
363
+ async function fetchDrugIndications(filter, limit) {
364
+ const url = `${CHEMBL_BASE}/drug_indication.json?${filter}&order_by=-max_phase_for_ind&limit=${limit}`;
365
+ const res = await apiFetchValidated(url, DrugIndicationsResponseSchema, { headers: HEADERS });
366
+ if (res.isErr()) {
367
+ if (res.error.type === "http_status" && res.error.status === 404)
368
+ return [];
369
+ throw new Error(describeApiError(res.error));
370
+ }
371
+ return res.value.drug_indications ?? [];
372
+ }
373
+ /** Read one batch of molecule records by id. */
374
+ async function fetchMoleculeSet(moleculeChemblIds) {
375
+ const res = await apiFetchValidated(`${CHEMBL_BASE}/molecule/set/${moleculeChemblIds.join(";")}.json`, MoleculeSearchResponseSchema, {
376
+ headers: HEADERS,
377
+ });
378
+ if (res.isErr()) {
379
+ if (res.error.type === "http_status" && res.error.status === 404)
380
+ return [];
381
+ throw new Error(describeApiError(res.error));
382
+ }
383
+ return res.value.molecules ?? [];
384
+ }
385
+ /** Collect the indication rows of each molecule, up to `maxMolecules` molecules. */
386
+ function groupIndicationsByMolecule(rows, maxMolecules) {
387
+ const byMolecule = new Map();
388
+ for (const row of rows) {
389
+ const moleculeChemblId = row.molecule_chembl_id;
390
+ if (!moleculeChemblId)
391
+ continue;
392
+ const collected = byMolecule.get(moleculeChemblId);
393
+ if (collected)
394
+ collected.push(row);
395
+ else if (byMolecule.size < maxMolecules)
396
+ byMolecule.set(moleculeChemblId, [row]);
338
397
  }
339
- if (drugRes.isErr() && !(drugRes.error.type === "http_status" && drugRes.error.status === 404)) {
340
- throw new Error(describeApiError(drugRes.error));
398
+ return byMolecule;
399
+ }
400
+ /** The drugs that ChEMBL indexes under a disease term, with the terms that matched. */
401
+ async function searchDrugsByIndication(query, limit) {
402
+ const encoded = encodeURIComponent(query);
403
+ // The two filters cover the two vocabularies of the resource. EFO comes first,
404
+ // because it carries the finer term, and a MeSH-only query still reaches the
405
+ // second read.
406
+ let rows = await fetchDrugIndications(`efo_term__icontains=${encoded}`, limit);
407
+ if (rows.length === 0)
408
+ rows = await fetchDrugIndications(`mesh_heading__icontains=${encoded}`, limit);
409
+ const byMolecule = groupIndicationsByMolecule(rows, limit);
410
+ if (byMolecule.size === 0)
411
+ return [];
412
+ const molecules = await fetchMoleculeSet([...byMolecule.keys()]);
413
+ return molecules.map((raw) => mapDrug(raw, joinIndicationTerms(byMolecule.get(raw.molecule_chembl_id ?? "") ?? [])));
414
+ }
415
+ /** The indication terms of each named molecule, keyed by the molecule id. */
416
+ async function fetchIndicationsOf(moleculeChemblIds) {
417
+ if (moleculeChemblIds.length === 0)
418
+ return new Map();
419
+ // A molecule carries one row for each disease term, thus the cap scales with the
420
+ // number of molecules that the caller resolved.
421
+ const rows = await fetchDrugIndications(`molecule_chembl_id__in=${moleculeChemblIds.join(",")}`, moleculeChemblIds.length * INDICATIONS_PER_DRUG);
422
+ const terms = new Map();
423
+ for (const [moleculeChemblId, collected] of groupIndicationsByMolecule(rows, moleculeChemblIds.length)) {
424
+ const joined = joinIndicationTerms(collected);
425
+ if (joined)
426
+ terms.set(moleculeChemblId, joined);
341
427
  }
342
- const molUrl = `${CHEMBL_BASE}/molecule/search.json?q=${encodeURIComponent(query)}&limit=${limit}`;
343
- const molRes = await apiFetchValidated(molUrl, MoleculeSearchResponseSchema, { headers: HEADERS });
344
- if (molRes.isErr()) {
345
- if (molRes.error.type === "http_status" && molRes.error.status === 404)
428
+ return terms;
429
+ }
430
+ /** The approved molecules whose name matches the query. */
431
+ async function searchApprovedDrugsByName(query, limit) {
432
+ const url = `${CHEMBL_BASE}/molecule/search.json?q=${encodeURIComponent(query)}&limit=${limit}`;
433
+ const res = await apiFetchValidated(url, MoleculeSearchResponseSchema, { headers: HEADERS });
434
+ if (res.isErr()) {
435
+ if (res.error.type === "http_status" && res.error.status === 404)
346
436
  return [];
347
- throw new Error(describeApiError(molRes.error));
437
+ throw new Error(describeApiError(res.error));
348
438
  }
349
- const approved = (molRes.value.molecules ?? []).filter((mol) => mol.max_phase != null && mol.max_phase >= 4);
350
- return approved.map((raw) => ({
351
- moleculeChemblId: raw.molecule_chembl_id ?? "",
352
- preferredName: raw.pref_name ?? null,
353
- maxPhase: raw.max_phase != null ? Number(raw.max_phase) : null,
354
- moleculeType: raw.molecule_type ?? null,
355
- firstApproval: raw.first_approval ?? null,
356
- indication: null,
357
- }));
439
+ const approved = (res.value.molecules ?? []).filter((mol) => mol.max_phase != null && mol.max_phase >= APPROVED_MAX_PHASE);
440
+ if (approved.length === 0)
441
+ return [];
442
+ const moleculeChemblIds = approved.map((mol) => mol.molecule_chembl_id).filter((id) => id !== undefined && id.length > 0);
443
+ const indications = await fetchIndicationsOf(moleculeChemblIds);
444
+ return approved.map((raw) => mapDrug(raw, indications.get(raw.molecule_chembl_id ?? "") ?? null));
445
+ }
446
+ /**
447
+ * Approved drugs by name or by disease term.
448
+ *
449
+ * The `drug` resource of ChEMBL has no search endpoint, and it carries neither a
450
+ * name nor an indication. The molecule name search runs first, because an
451
+ * `icontains` read of `drug_indication` captures a drug name that is a substring
452
+ * of a disease term ("insulin" matches "insulin resistance"). A query that
453
+ * names no molecule falls through to the indication read, which answers
454
+ * highest-phase first and can carry a clinical-stage molecule below approval.
455
+ */
456
+ export async function getDrugInfo(query, limit = 25) {
457
+ const byName = await searchApprovedDrugsByName(query, limit);
458
+ if (byName.length > 0)
459
+ return byName;
460
+ return searchDrugsByIndication(query, limit);
358
461
  }
359
462
  /**
360
463
  * Fetch all clinical-stage modulators (max_phase ≥ 2 by default) for a target.
361
464
  * Used for §2.6.6 (class precedent) and as the input to §2.6.4 (off-target panel).
362
465
  */
363
- export async function getTargetModulators(targetChemblId, options = {}) {
466
+ export async function getTargetModulators(targetChemblId, options = {}, logger) {
467
+ const log = (logger ?? createNoopLogger()).named("chembl-client.target-modulators").with({ targetChemblId });
364
468
  const minPhase = options.minPhase ?? 2;
365
469
  const limit = options.limit ?? 200;
366
470
  const mechUrl = `${CHEMBL_BASE}/mechanism.json?target_chembl_id=${encodeURIComponent(targetChemblId)}` + `&limit=${limit}`;
@@ -386,10 +490,12 @@ export async function getTargetModulators(targetChemblId, options = {}) {
386
490
  const res = await apiFetchValidated(`${CHEMBL_BASE}/molecule/${id}.json`, RawChemblMoleculeSchema, {
387
491
  headers: HEADERS,
388
492
  });
389
- if (res.isErr())
493
+ if (res.isErr()) {
494
+ reportSkippedMolecule(log, res.error, id);
390
495
  return null;
496
+ }
391
497
  const m = res.value;
392
- const phase = m.max_phase != null ? Number(m.max_phase) : null;
498
+ const phase = m.max_phase ?? null;
393
499
  if (phase == null || phase < minPhase)
394
500
  return null;
395
501
  return {
@@ -429,7 +535,7 @@ export async function getModulatorPolypharmacology(moleculeChemblId, options = {
429
535
  continue;
430
536
  if (options.excludeTargetChemblId && tid === options.excludeTargetChemblId)
431
537
  continue;
432
- const pchembl = parseNumeric(raw.pchembl_value);
538
+ const pchembl = raw.pchembl_value ?? null;
433
539
  const existing = byTarget.get(tid);
434
540
  if (!existing || (pchembl != null && (existing.pchemblValue == null || pchembl > existing.pchemblValue))) {
435
541
  byTarget.set(tid, {
@@ -438,7 +544,7 @@ export async function getModulatorPolypharmacology(moleculeChemblId, options = {
438
544
  targetName: null,
439
545
  assayChemblId: raw.assay_chembl_id ?? null,
440
546
  standardType: raw.standard_type ?? null,
441
- standardValue: parseNumeric(raw.standard_value),
547
+ standardValue: raw.standard_value ?? null,
442
548
  standardUnits: raw.standard_units ?? null,
443
549
  pchemblValue: pchembl,
444
550
  });
@@ -466,14 +572,17 @@ export async function getModulatorPolypharmacology(moleculeChemblId, options = {
466
572
  * Used by the polypharm fanout step so the assembler's computeSelectivity
467
573
  * can produce real fold values instead of selectivity_unknown.
468
574
  */
469
- export async function getModulatorOnTargetPchembl(modulatorChemblId, targetChemblId) {
575
+ export async function getModulatorOnTargetPchembl(modulatorChemblId, targetChemblId, logger) {
576
+ const log = (logger ?? createNoopLogger()).named("chembl-client.on-target-pchembl").with({ targetChemblId });
470
577
  const url = `${CHEMBL_BASE}/activity.json?molecule_chembl_id=${encodeURIComponent(modulatorChemblId)}` +
471
578
  `&target_chembl_id=${encodeURIComponent(targetChemblId)}` +
472
579
  `&pchembl_value__isnull=false&limit=10`;
473
580
  const res = await apiFetchValidated(url, ActivitiesResponseSchema, { headers: HEADERS });
474
- if (res.isErr())
581
+ if (res.isErr()) {
582
+ reportSkippedMolecule(log, res.error, modulatorChemblId);
475
583
  return null;
476
- const values = (res.value.activities ?? []).map((a) => parseNumeric(a.pchembl_value)).filter((v) => v !== null);
584
+ }
585
+ const values = (res.value.activities ?? []).map((a) => a.pchembl_value).filter((v) => v != null);
477
586
  if (values.length === 0)
478
587
  return null;
479
588
  values.sort((a, b) => a - b);
@@ -493,8 +602,9 @@ export async function getModulatorOnTargetPchembl(modulatorChemblId, targetChemb
493
602
  * Returns one `ChemblModulator` per unique molecule that passes the
494
603
  * min-phase and molecule-type filters.
495
604
  */
496
- export async function getModulatorsViaActivity(args) {
605
+ export async function getModulatorsViaActivity(args, logger) {
497
606
  const { target_chembl_id, max_phase_min = 2, molecule_type, limit = 50 } = args;
607
+ const log = (logger ?? createNoopLogger()).named("chembl-client.modulators-via-activity").with({ targetChemblId: target_chembl_id });
498
608
  let url = `${CHEMBL_BASE}/activity.json?target_chembl_id=${encodeURIComponent(target_chembl_id)}` + `&max_phase__gte=${max_phase_min}&limit=${limit}`;
499
609
  if (molecule_type) {
500
610
  url += `&molecule_type=${encodeURIComponent(molecule_type)}`;
@@ -522,10 +632,12 @@ export async function getModulatorsViaActivity(args) {
522
632
  const molRes = await apiFetchValidated(`${CHEMBL_BASE}/molecule/${id}.json`, RawChemblMoleculeSchema, {
523
633
  headers: HEADERS,
524
634
  });
525
- if (molRes.isErr())
635
+ if (molRes.isErr()) {
636
+ reportSkippedMolecule(log, molRes.error, id);
526
637
  return null;
638
+ }
527
639
  const m = molRes.value;
528
- const phase = m.max_phase != null ? Number(m.max_phase) : null;
640
+ const phase = m.max_phase ?? null;
529
641
  if (phase == null || phase < max_phase_min)
530
642
  return null;
531
643
  const molType = m.molecule_type ?? null;
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Absence policy: this file calls no external API, thus no wire encodes an
3
+ * absent value. The bundled dataset gives each key, and `getBenchmarks` answers
4
+ * with the "all" baseline for an unknown therapeutic area.
5
+ */
1
6
  export interface PhaseTransitions {
2
7
  phase1_to_phase2: number;
3
8
  phase2_to_phase3: number;
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Absence policy: this file calls no external API, thus no wire encodes an
3
+ * absent value. The bundled dataset gives each key, and `getBenchmarks` answers
4
+ * with the "all" baseline for an unknown therapeutic area.
5
+ */
1
6
  import benchmarksData from "../../data/clinical-benchmarks.json" with { type: "json" };
2
7
  const dataset = benchmarksData;
3
8
  if (!dataset.phase_transitions || !dataset.phase_transitions.all) {
@@ -3,6 +3,10 @@
3
3
  *
4
4
  * Used by target-assessment workflow steps for §2.5 (clinical record),
5
5
  * §2.6.3 (trial AEs), §2.5 (failed trials), and §6.5 (discovery trials).
6
+ *
7
+ * Absence policy: the API omits the key of an absent value, and its OpenAPI
8
+ * document marks nothing as nullable. Thus a maybe-absent field carries
9
+ * `.optional()`, not `.nullable()`.
6
10
  */
7
11
  import { z } from "zod";
8
12
  export interface ClinicalTrial {
@@ -70,12 +74,18 @@ export interface TrialDetails {
70
74
  adverseEventGroups: AdverseEventGroup[];
71
75
  adverseEvents: AdverseEvent[];
72
76
  }
77
+ /**
78
+ * One phase token of `protocolSection.designModule.phases`. `NA` marks a study
79
+ * that runs under no phase, such as an observational study.
80
+ */
81
+ export type TrialPhase = "EARLY_PHASE1" | "PHASE1" | "PHASE2" | "PHASE3" | "PHASE4" | "NA";
73
82
  export interface SearchOptions {
74
- phase?: "EARLY_PHASE1" | "PHASE1" | "PHASE2" | "PHASE3" | "PHASE4";
83
+ phase?: TrialPhase;
75
84
  status?: "RECRUITING" | "ACTIVE_NOT_RECRUITING" | "COMPLETED" | "NOT_YET_RECRUITING" | "TERMINATED" | "WITHDRAWN" | "SUSPENDED";
76
85
  limit?: number;
77
86
  }
78
- declare const CtStudySchema: z.ZodObject<{
87
+ /** One study record. It is exported so that the golden-fixture table drives it. */
88
+ export declare const CtStudySchema: z.ZodObject<{
79
89
  protocolSection: z.ZodOptional<z.ZodObject<{
80
90
  identificationModule: z.ZodOptional<z.ZodObject<{
81
91
  nctId: z.ZodOptional<z.ZodString>;
@@ -184,7 +194,126 @@ declare const CtStudySchema: z.ZodObject<{
184
194
  }, z.core.$strip>>;
185
195
  }, z.core.$strip>;
186
196
  type CtStudy = z.infer<typeof CtStudySchema>;
197
+ /** The search envelope. It is exported so that the golden-fixture table drives it. */
198
+ export declare const CtSearchResponseSchema: z.ZodObject<{
199
+ studies: z.ZodOptional<z.ZodArray<z.ZodObject<{
200
+ protocolSection: z.ZodOptional<z.ZodObject<{
201
+ identificationModule: z.ZodOptional<z.ZodObject<{
202
+ nctId: z.ZodOptional<z.ZodString>;
203
+ briefTitle: z.ZodOptional<z.ZodString>;
204
+ officialTitle: z.ZodOptional<z.ZodString>;
205
+ }, z.core.$strip>>;
206
+ statusModule: z.ZodOptional<z.ZodObject<{
207
+ overallStatus: z.ZodOptional<z.ZodString>;
208
+ whyStopped: z.ZodOptional<z.ZodString>;
209
+ startDateStruct: z.ZodOptional<z.ZodObject<{
210
+ date: z.ZodOptional<z.ZodString>;
211
+ }, z.core.$strip>>;
212
+ primaryCompletionDateStruct: z.ZodOptional<z.ZodObject<{
213
+ date: z.ZodOptional<z.ZodString>;
214
+ }, z.core.$strip>>;
215
+ }, z.core.$strip>>;
216
+ designModule: z.ZodOptional<z.ZodObject<{
217
+ phases: z.ZodOptional<z.ZodArray<z.ZodString>>;
218
+ studyType: z.ZodOptional<z.ZodString>;
219
+ designInfo: z.ZodOptional<z.ZodObject<{
220
+ primaryPurpose: z.ZodOptional<z.ZodString>;
221
+ }, z.core.$strip>>;
222
+ enrollmentInfo: z.ZodOptional<z.ZodObject<{
223
+ count: z.ZodOptional<z.ZodNumber>;
224
+ }, z.core.$strip>>;
225
+ }, z.core.$strip>>;
226
+ descriptionModule: z.ZodOptional<z.ZodObject<{
227
+ briefSummary: z.ZodOptional<z.ZodString>;
228
+ detailedDescription: z.ZodOptional<z.ZodString>;
229
+ }, z.core.$strip>>;
230
+ conditionsModule: z.ZodOptional<z.ZodObject<{
231
+ conditions: z.ZodOptional<z.ZodArray<z.ZodString>>;
232
+ }, z.core.$strip>>;
233
+ armsInterventionsModule: z.ZodOptional<z.ZodObject<{
234
+ interventions: z.ZodOptional<z.ZodArray<z.ZodObject<{
235
+ name: z.ZodOptional<z.ZodString>;
236
+ type: z.ZodOptional<z.ZodString>;
237
+ description: z.ZodOptional<z.ZodString>;
238
+ otherNames: z.ZodOptional<z.ZodUnknown>;
239
+ }, z.core.$strip>>>;
240
+ }, z.core.$strip>>;
241
+ sponsorCollaboratorsModule: z.ZodOptional<z.ZodObject<{
242
+ leadSponsor: z.ZodOptional<z.ZodObject<{
243
+ name: z.ZodOptional<z.ZodString>;
244
+ }, z.core.$strip>>;
245
+ }, z.core.$strip>>;
246
+ outcomesModule: z.ZodOptional<z.ZodObject<{
247
+ primaryOutcomes: z.ZodOptional<z.ZodArray<z.ZodObject<{
248
+ measure: z.ZodOptional<z.ZodString>;
249
+ description: z.ZodOptional<z.ZodString>;
250
+ timeFrame: z.ZodOptional<z.ZodString>;
251
+ }, z.core.$strip>>>;
252
+ secondaryOutcomes: z.ZodOptional<z.ZodArray<z.ZodObject<{
253
+ measure: z.ZodOptional<z.ZodString>;
254
+ description: z.ZodOptional<z.ZodString>;
255
+ timeFrame: z.ZodOptional<z.ZodString>;
256
+ }, z.core.$strip>>>;
257
+ otherOutcomes: z.ZodOptional<z.ZodArray<z.ZodObject<{
258
+ measure: z.ZodOptional<z.ZodString>;
259
+ description: z.ZodOptional<z.ZodString>;
260
+ timeFrame: z.ZodOptional<z.ZodString>;
261
+ }, z.core.$strip>>>;
262
+ }, z.core.$strip>>;
263
+ }, z.core.$strip>>;
264
+ resultsSection: z.ZodOptional<z.ZodObject<{
265
+ outcomeMeasuresModule: z.ZodOptional<z.ZodObject<{
266
+ outcomeMeasures: z.ZodOptional<z.ZodArray<z.ZodObject<{
267
+ title: z.ZodOptional<z.ZodString>;
268
+ unitOfMeasure: z.ZodOptional<z.ZodString>;
269
+ classes: z.ZodOptional<z.ZodArray<z.ZodObject<{
270
+ categories: z.ZodOptional<z.ZodArray<z.ZodObject<{
271
+ measurements: z.ZodOptional<z.ZodArray<z.ZodObject<{
272
+ value: z.ZodOptional<z.ZodString>;
273
+ lowerLimit: z.ZodOptional<z.ZodString>;
274
+ upperLimit: z.ZodOptional<z.ZodString>;
275
+ }, z.core.$strip>>>;
276
+ }, z.core.$strip>>>;
277
+ }, z.core.$strip>>>;
278
+ }, z.core.$strip>>>;
279
+ }, z.core.$strip>>;
280
+ adverseEventsModule: z.ZodOptional<z.ZodObject<{
281
+ eventGroups: z.ZodOptional<z.ZodArray<z.ZodObject<{
282
+ id: z.ZodOptional<z.ZodString>;
283
+ title: z.ZodOptional<z.ZodString>;
284
+ description: z.ZodOptional<z.ZodString>;
285
+ }, z.core.$strip>>>;
286
+ seriousEvents: z.ZodOptional<z.ZodArray<z.ZodObject<{
287
+ term: z.ZodOptional<z.ZodString>;
288
+ organSystem: z.ZodOptional<z.ZodString>;
289
+ stats: z.ZodOptional<z.ZodArray<z.ZodObject<{
290
+ groupId: z.ZodOptional<z.ZodString>;
291
+ numAffected: z.ZodOptional<z.ZodNumber>;
292
+ numAtRisk: z.ZodOptional<z.ZodNumber>;
293
+ }, z.core.$strip>>>;
294
+ }, z.core.$strip>>>;
295
+ otherEvents: z.ZodOptional<z.ZodArray<z.ZodObject<{
296
+ term: z.ZodOptional<z.ZodString>;
297
+ organSystem: z.ZodOptional<z.ZodString>;
298
+ stats: z.ZodOptional<z.ZodArray<z.ZodObject<{
299
+ groupId: z.ZodOptional<z.ZodString>;
300
+ numAffected: z.ZodOptional<z.ZodNumber>;
301
+ numAtRisk: z.ZodOptional<z.ZodNumber>;
302
+ }, z.core.$strip>>>;
303
+ }, z.core.$strip>>>;
304
+ }, z.core.$strip>>;
305
+ }, z.core.$strip>>;
306
+ }, z.core.$strip>>>;
307
+ totalCount: z.ZodOptional<z.ZodNumber>;
308
+ }, z.core.$strip>;
187
309
  export declare function mapClinicalTrialStudy(s: CtStudy): ClinicalTrial;
310
+ /**
311
+ * Search the registry.
312
+ *
313
+ * `totalFound` is the count of the whole query, which `countTotal=true` asks
314
+ * for. A phase filter runs over the returned page only, thus `totalFound` can
315
+ * exceed the number of trials that the answer carries.
316
+ */
188
317
  export declare function searchTrials(query: string, options?: SearchOptions): Promise<{
189
318
  totalFound: number;
190
319
  trials: ClinicalTrial[];