@inflexa-ai/harness 0.16.0 → 0.18.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 (295) hide show
  1. package/dist/agents/conversation-agent.d.ts +3 -0
  2. package/dist/agents/conversation-agent.js +5 -2
  3. package/dist/agents/report-builder.d.ts +2 -0
  4. package/dist/agents/report-builder.js +2 -0
  5. package/dist/agents/sandbox/shared.d.ts +3 -0
  6. package/dist/agents/sandbox/shared.js +5 -0
  7. package/dist/agents/sandbox/types.d.ts +1 -1
  8. package/dist/app/chat-turn.d.ts +2 -0
  9. package/dist/app/chat-turn.js +11 -2
  10. package/dist/app/spawn-report-session.d.ts +85 -0
  11. package/dist/app/spawn-report-session.js +83 -0
  12. package/dist/app/synthesize-run.d.ts +3 -0
  13. package/dist/app/synthesize-run.js +2 -1
  14. package/dist/citations/__fixtures__/resolver.d.ts +3 -0
  15. package/dist/citations/__fixtures__/resolver.js +9 -0
  16. package/dist/citations/aggregate.d.ts +3 -0
  17. package/dist/citations/aggregate.js +44 -0
  18. package/dist/citations/clients/arxiv.d.ts +6 -0
  19. package/dist/citations/clients/arxiv.js +34 -0
  20. package/dist/citations/clients/common.d.ts +11 -0
  21. package/dist/citations/clients/common.js +43 -0
  22. package/dist/citations/clients/crossref.d.ts +8 -0
  23. package/dist/citations/clients/crossref.js +98 -0
  24. package/dist/citations/clients/doi-registry.d.ts +3 -0
  25. package/dist/citations/clients/doi-registry.js +95 -0
  26. package/dist/citations/clients/pubmed.d.ts +6 -0
  27. package/dist/citations/clients/pubmed.js +83 -0
  28. package/dist/citations/clients/semantic-scholar.d.ts +6 -0
  29. package/dist/citations/clients/semantic-scholar.js +60 -0
  30. package/dist/citations/compare.d.ts +9 -0
  31. package/dist/citations/compare.js +72 -0
  32. package/dist/citations/match.d.ts +11 -0
  33. package/dist/citations/match.js +173 -0
  34. package/dist/citations/normalize.d.ts +31 -0
  35. package/dist/citations/normalize.js +187 -0
  36. package/dist/citations/plan.d.ts +2 -0
  37. package/dist/citations/plan.js +59 -0
  38. package/dist/citations/resolve.d.ts +28 -0
  39. package/dist/citations/resolve.js +238 -0
  40. package/dist/citations/types.d.ts +583 -0
  41. package/dist/citations/types.js +129 -0
  42. package/dist/contracts/chat-events.d.ts +9 -0
  43. package/dist/contracts/index.d.ts +6 -2
  44. package/dist/contracts/index.js +3 -1
  45. package/dist/contracts/organ-system.d.ts +49 -0
  46. package/dist/contracts/organ-system.js +67 -0
  47. package/dist/contracts/report-blocks.d.ts +564 -0
  48. package/dist/contracts/report-blocks.js +117 -0
  49. package/dist/contracts/report-reference.d.ts +459 -0
  50. package/dist/contracts/report-reference.js +256 -0
  51. package/dist/contracts/schemas/chat-events.d.ts +2 -0
  52. package/dist/contracts/schemas/chat-events.js +2 -0
  53. package/dist/contracts/schemas/chat-parts.d.ts +8 -8
  54. package/dist/contracts/severity.d.ts +15 -0
  55. package/dist/contracts/severity.js +10 -0
  56. package/dist/contracts/target-dossier.d.ts +8673 -12970
  57. package/dist/contracts/target-dossier.js +534 -591
  58. package/dist/data/safety-panel-schema.d.ts +18 -5
  59. package/dist/data/safety-panel-schema.js +4 -4
  60. package/dist/execution/artifact-metadata.js +1 -0
  61. package/dist/execution/report-runner.js +1 -3
  62. package/dist/execution/run-synthesis.d.ts +3 -0
  63. package/dist/execution/run-synthesis.js +3 -0
  64. package/dist/index.d.ts +12 -1
  65. package/dist/index.js +22 -0
  66. package/dist/lib/async-utils.d.ts +16 -4
  67. package/dist/lib/async-utils.js +20 -15
  68. package/dist/lib/cache.d.ts +8 -0
  69. package/dist/lib/cache.js +35 -0
  70. package/dist/lib/host-concurrency.js +1 -0
  71. package/dist/lib/rate-limit.d.ts +35 -0
  72. package/dist/lib/rate-limit.js +121 -0
  73. package/dist/literature/sources/arxiv.d.ts +22 -0
  74. package/dist/literature/sources/arxiv.js +103 -0
  75. package/dist/literature/sources/http.d.ts +28 -0
  76. package/dist/literature/sources/http.js +97 -0
  77. package/dist/literature/sources/pubmed.d.ts +68 -0
  78. package/dist/literature/sources/pubmed.js +266 -0
  79. package/dist/literature/sources/semantic-scholar.d.ts +24 -0
  80. package/dist/literature/sources/semantic-scholar.js +71 -0
  81. package/dist/loop/__fixtures__/scripted-provider.js +23 -2
  82. package/dist/loop/run-agent.js +58 -10
  83. package/dist/loop/tool-detail.d.ts +40 -0
  84. package/dist/loop/tool-detail.js +35 -3
  85. package/dist/loop/types.d.ts +13 -0
  86. package/dist/memory/thread-history.d.ts +10 -0
  87. package/dist/memory/thread-history.js +12 -1
  88. package/dist/memory/thread-store.d.ts +142 -25
  89. package/dist/memory/thread-store.js +228 -40
  90. package/dist/prompts/literature-reviewer.d.ts +1 -1
  91. package/dist/prompts/literature-reviewer.js +2 -0
  92. package/dist/prompts/target-assessment/briefs/executive-recommendation.d.ts +1 -1
  93. package/dist/prompts/target-assessment/briefs/executive-recommendation.js +5 -5
  94. package/dist/prompts/target-assessment/briefs/liability-bullets.d.ts +1 -1
  95. package/dist/prompts/target-assessment/briefs/liability-bullets.js +1 -1
  96. package/dist/prompts/target-assessment/briefs/target-organ-liabilities.d.ts +1 -1
  97. package/dist/prompts/target-assessment/briefs/target-organ-liabilities.js +11 -3
  98. package/dist/prompts/target-assessment/investigation/adversarial-critique.d.ts +1 -0
  99. package/dist/prompts/target-assessment/investigation/adversarial-critique.js +67 -0
  100. package/dist/prompts/target-assessment/investigation/claim-reverification.d.ts +1 -0
  101. package/dist/prompts/target-assessment/investigation/claim-reverification.js +49 -0
  102. package/dist/prompts/target-assessment/investigation/index.d.ts +3 -0
  103. package/dist/prompts/target-assessment/investigation/index.js +3 -0
  104. package/dist/prompts/target-assessment/investigation/mechanism-proposal.d.ts +1 -0
  105. package/dist/prompts/target-assessment/investigation/mechanism-proposal.js +49 -0
  106. package/dist/prompts/target-assessment/tox-voice/index.d.ts +1 -1
  107. package/dist/prompts/target-assessment/tox-voice/vocabulary.d.ts +8 -5
  108. package/dist/prompts/target-assessment/tox-voice/vocabulary.js +9 -20
  109. package/dist/providers/__fixtures__/session.d.ts +5 -4
  110. package/dist/providers/__fixtures__/session.js +4 -4
  111. package/dist/report-model/block-walk.d.ts +56 -0
  112. package/dist/report-model/block-walk.js +89 -0
  113. package/dist/report-model/draft-finish.d.ts +60 -0
  114. package/dist/report-model/draft-finish.js +56 -0
  115. package/dist/report-model/draft-operations.d.ts +125 -0
  116. package/dist/report-model/draft-operations.js +341 -0
  117. package/dist/report-model/draft-read.d.ts +59 -0
  118. package/dist/report-model/draft-read.js +109 -0
  119. package/dist/report-model/draft.d.ts +51 -0
  120. package/dist/report-model/draft.js +32 -0
  121. package/dist/report-model/fixture-resolver.d.ts +8 -0
  122. package/dist/report-model/fixture-resolver.js +252 -0
  123. package/dist/report-model/mint-snapshot.d.ts +30 -0
  124. package/dist/report-model/mint-snapshot.js +40 -0
  125. package/dist/report-model/reference-resolver.d.ts +113 -0
  126. package/dist/report-model/reference-resolver.js +60 -0
  127. package/dist/report-model/structural-validation.d.ts +22 -0
  128. package/dist/report-model/structural-validation.js +68 -0
  129. package/dist/report-model/validate.d.ts +40 -0
  130. package/dist/report-model/validate.js +80 -0
  131. package/dist/report-render/chart.d.ts +33 -0
  132. package/dist/report-render/chart.js +538 -0
  133. package/dist/report-render/page.d.ts +127 -0
  134. package/dist/report-render/page.js +307 -0
  135. package/dist/report-render/references.d.ts +32 -0
  136. package/dist/report-render/references.js +44 -0
  137. package/dist/report-render/render.d.ts +25 -0
  138. package/dist/report-render/render.js +125 -0
  139. package/dist/report-render/script-json.d.ts +13 -0
  140. package/dist/report-render/script-json.js +15 -0
  141. package/dist/report-render/types.d.ts +39 -0
  142. package/dist/report-render/types.js +10 -0
  143. package/dist/report-render/views/chart-view.d.ts +11 -0
  144. package/dist/report-render/views/chart-view.js +23 -0
  145. package/dist/report-render/views/page-view.d.ts +20 -0
  146. package/dist/report-render/views/page-view.js +49 -0
  147. package/dist/report-render/views/prose.d.ts +28 -0
  148. package/dist/report-render/views/prose.js +75 -0
  149. package/dist/report-render/views/references-view.d.ts +20 -0
  150. package/dist/report-render/views/references-view.js +64 -0
  151. package/dist/report-render/views/values.d.ts +42 -0
  152. package/dist/report-render/views/values.js +43 -0
  153. package/dist/runtime/assemble.d.ts +50 -4
  154. package/dist/runtime/assemble.js +47 -3
  155. package/dist/runtime/boot.d.ts +3 -3
  156. package/dist/runtime/boot.js +22 -5
  157. package/dist/runtime/dbos.d.ts +4 -4
  158. package/dist/state/artifacts.d.ts +16 -0
  159. package/dist/state/artifacts.js +18 -0
  160. package/dist/state/init.js +77 -1
  161. package/dist/state/purge-analysis.js +1 -0
  162. package/dist/state/report-versions.d.ts +132 -0
  163. package/dist/state/report-versions.js +194 -0
  164. package/dist/state/schema.d.ts +2 -2
  165. package/dist/tasks/data-profile.js +1 -0
  166. package/dist/tools/bio/chembl.js +1 -0
  167. package/dist/tools/bio/comptox.js +1 -0
  168. package/dist/tools/bio/drug-gene-interactions.js +1 -0
  169. package/dist/tools/bio/gene-disease-evidence.js +1 -0
  170. package/dist/tools/bio/gene-preclinical-profile.d.ts +1 -1
  171. package/dist/tools/bio/gene-preclinical-profile.js +1 -0
  172. package/dist/tools/bio/keys.d.ts +4 -2
  173. package/dist/tools/bio/keys.js +3 -2
  174. package/dist/tools/bio/lookup-annotation.js +1 -0
  175. package/dist/tools/bio/opentargets.d.ts +3 -3
  176. package/dist/tools/bio/opentargets.js +4 -3
  177. package/dist/tools/bio/pubchem.js +1 -0
  178. package/dist/tools/bio/pubmed.js +4 -4
  179. package/dist/tools/bio/search-clinical-trials.js +1 -0
  180. package/dist/tools/bio/search-faers.js +1 -0
  181. package/dist/tools/bio/search-geo-datasets.js +1 -0
  182. package/dist/tools/bio/search-interactions.js +1 -0
  183. package/dist/tools/bio/target-safety.d.ts +4 -6
  184. package/dist/tools/bio/target-safety.js +7 -6
  185. package/dist/tools/define-tool.d.ts +19 -12
  186. package/dist/tools/define-tool.js +4 -2
  187. package/dist/tools/display/show-user.js +1 -0
  188. package/dist/tools/iterate-report.js +2 -0
  189. package/dist/tools/lib/chembl-client.d.ts +2 -0
  190. package/dist/tools/lib/chembl-client.js +1 -0
  191. package/dist/tools/lib/monarch-client.d.ts +42 -0
  192. package/dist/tools/lib/monarch-client.js +100 -0
  193. package/dist/tools/lib/ncbi-utils.d.ts +3 -58
  194. package/dist/tools/lib/ncbi-utils.js +2 -286
  195. package/dist/tools/lib/pubmed-client.d.ts +5 -12
  196. package/dist/tools/lib/pubmed-client.js +20 -79
  197. package/dist/tools/report/build-report.js +1 -0
  198. package/dist/tools/report/mint-preview-url.js +1 -0
  199. package/dist/tools/report/preview-snapshot.js +1 -0
  200. package/dist/tools/report/submit-report.js +1 -0
  201. package/dist/tools/report/version-fs.js +4 -0
  202. package/dist/tools/report-authoring/authoring-tools.d.ts +134 -0
  203. package/dist/tools/report-authoring/authoring-tools.js +352 -0
  204. package/dist/tools/research/context7-docs.js +2 -0
  205. package/dist/tools/research/generate-analogy-report.js +3 -2
  206. package/dist/tools/research/generate-plan.js +3 -0
  207. package/dist/tools/research/index.d.ts +1 -0
  208. package/dist/tools/research/index.js +1 -0
  209. package/dist/tools/research/inspect-data-profile.js +4 -0
  210. package/dist/tools/research/inspect-run.d.ts +1 -1
  211. package/dist/tools/research/literature-reviewer.d.ts +3 -0
  212. package/dist/tools/research/literature-reviewer.js +3 -0
  213. package/dist/tools/research/resolve-citation.d.ts +3 -0
  214. package/dist/tools/research/resolve-citation.js +15 -0
  215. package/dist/tools/research/search-arxiv.d.ts +3 -19
  216. package/dist/tools/research/search-arxiv.js +10 -105
  217. package/dist/tools/research/search-github-repos.js +1 -0
  218. package/dist/tools/research/search-semantic-scholar.d.ts +8 -33
  219. package/dist/tools/research/search-semantic-scholar.js +30 -88
  220. package/dist/tools/sandbox/list-available-packages.js +23 -0
  221. package/dist/tools/sandbox/list-available-refs.js +37 -2
  222. package/dist/tools/sandbox/report-blocker.js +1 -0
  223. package/dist/tools/sandbox/skills.js +2 -0
  224. package/dist/tools/workspace/file-stat.js +1 -0
  225. package/dist/tools/workspace/show-file.js +2 -0
  226. package/dist/tools/workspace/show-plan.js +1 -0
  227. package/dist/workflows/execute-analysis.d.ts +8 -1
  228. package/dist/workflows/execute-analysis.js +6 -5
  229. package/dist/workflows/execute-target-assessment.d.ts +13 -2
  230. package/dist/workflows/execute-target-assessment.js +78 -16
  231. package/dist/workflows/sandbox-step.d.ts +5 -0
  232. package/dist/workflows/sandbox-step.js +1 -0
  233. package/dist/workflows/target-assessment/assemblers/index.d.ts +1 -1
  234. package/dist/workflows/target-assessment/assemblers/index.js +1 -1
  235. package/dist/workflows/target-assessment/assemblers/literature.d.ts +9 -27
  236. package/dist/workflows/target-assessment/assemblers/literature.js +32 -48
  237. package/dist/workflows/target-assessment/assemblers/orchestrator.d.ts +17 -9
  238. package/dist/workflows/target-assessment/assemblers/orchestrator.js +195 -85
  239. package/dist/workflows/target-assessment/assemblers/safety.d.ts +70 -48
  240. package/dist/workflows/target-assessment/assemblers/safety.js +125 -42
  241. package/dist/workflows/target-assessment/collectors/index.d.ts +5 -2
  242. package/dist/workflows/target-assessment/collectors/index.js +48 -5
  243. package/dist/workflows/target-assessment/coverage.d.ts +43 -27
  244. package/dist/workflows/target-assessment/coverage.js +33 -29
  245. package/dist/workflows/target-assessment/fanout/index.js +1 -0
  246. package/dist/workflows/target-assessment/investigation/index.d.ts +97 -0
  247. package/dist/workflows/target-assessment/investigation/index.js +419 -0
  248. package/dist/workflows/target-assessment/lib/approval-precedents.d.ts +26 -5
  249. package/dist/workflows/target-assessment/lib/approval-precedents.js +95 -29
  250. package/dist/workflows/target-assessment/lib/citation-validator.js +1 -1
  251. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.d.ts +2 -2
  252. package/dist/workflows/target-assessment/lib/clinical-evidence-attribution.d.ts +2 -3
  253. package/dist/workflows/target-assessment/lib/clinical-evidence-attribution.js +0 -4
  254. package/dist/workflows/target-assessment/lib/compute-derived.d.ts +6 -5
  255. package/dist/workflows/target-assessment/lib/compute-derived.js +14 -5
  256. package/dist/workflows/target-assessment/lib/family-complex-supplement.d.ts +5 -6
  257. package/dist/workflows/target-assessment/lib/family-complex-supplement.js +2 -3
  258. package/dist/workflows/target-assessment/lib/fda-label-safety.d.ts +72 -0
  259. package/dist/workflows/target-assessment/lib/fda-label-safety.js +166 -0
  260. package/dist/workflows/target-assessment/lib/heterodimer-filter.d.ts +0 -9
  261. package/dist/workflows/target-assessment/lib/heterodimer-filter.js +0 -10
  262. package/dist/workflows/target-assessment/lib/hpo-organ-map.d.ts +34 -0
  263. package/dist/workflows/target-assessment/lib/hpo-organ-map.js +63 -0
  264. package/dist/workflows/target-assessment/lib/impc-organ-map.d.ts +19 -0
  265. package/dist/workflows/target-assessment/lib/impc-organ-map.js +39 -0
  266. package/dist/workflows/target-assessment/lib/meddra-organ-map.d.ts +24 -14
  267. package/dist/workflows/target-assessment/lib/meddra-organ-map.js +59 -47
  268. package/dist/workflows/target-assessment/lib/recommendation-sanitizer.js +1 -1
  269. package/dist/workflows/target-assessment/lib/regulatory-actions.d.ts +2 -3
  270. package/dist/workflows/target-assessment/lib/regulatory-actions.js +33 -4
  271. package/dist/workflows/target-assessment/lib/safety-corroboration.d.ts +44 -0
  272. package/dist/workflows/target-assessment/lib/safety-corroboration.js +273 -0
  273. package/dist/workflows/target-assessment/lib/target-identity-filter.d.ts +5 -7
  274. package/dist/workflows/target-assessment/lib/target-identity-filter.js +5 -7
  275. package/dist/workflows/target-assessment/metrics.d.ts +1 -6
  276. package/dist/workflows/target-assessment/metrics.js +1 -13
  277. package/dist/workflows/target-assessment/phase4-assemble.d.ts +2149 -809
  278. package/dist/workflows/target-assessment/phase4-assemble.js +19 -19
  279. package/dist/workflows/target-assessment/phase5-persist.d.ts +20 -21
  280. package/dist/workflows/target-assessment/phase5-persist.js +56 -173
  281. package/dist/workflows/target-assessment/schemas.d.ts +48 -3
  282. package/dist/workflows/target-assessment/schemas.js +19 -0
  283. package/dist/workflows/target-assessment/steps/fanout/polypharm-for-one-modulator-step.d.ts +1 -0
  284. package/dist/workflows/target-assessment/steps/fanout/polypharm-for-one-modulator-step.js +2 -0
  285. package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +27 -63
  286. package/dist/workflows/target-assessment/steps/phase2-aggregate.js +0 -4
  287. package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +28 -155
  288. package/dist/workflows/target-assessment/steps/phase3-aggregate.js +0 -6
  289. package/dist/workflows/target-assessment/steps/synthesis/diagnostics.d.ts +0 -6
  290. package/dist/workflows/target-assessment/steps/synthesis/diagnostics.js +0 -18
  291. package/dist/workflows/target-assessment/sub-workflows/per-modulator-polypharm.d.ts +1 -0
  292. package/dist/workflows/target-assessment/synthesis/index.d.ts +52 -5
  293. package/dist/workflows/target-assessment/synthesis/index.js +4 -1
  294. package/dist/workflows/target-assessment/synthesis-probes.d.ts +3 -3
  295. package/package.json +4 -1
@@ -0,0 +1,419 @@
1
+ /**
2
+ * The claim-investigation phase.
3
+ *
4
+ * Corroboration says which organs several sources agree on. Agreement is not
5
+ * adjudication: sources that all re-import one curation agree perfectly and are
6
+ * wrong together. This phase interrogates each corroborated organ claim —
7
+ * propose a mechanism, argue against it, re-verify, converge — and records what
8
+ * survived, plus every candidate it did not get to and why.
9
+ *
10
+ * Three LLM steps per round, chosen per step rather than uniformly:
11
+ *
12
+ * - **propose** and **re-verify** are single-shot structured extractions
13
+ * (`structuredLlmCall`). Everything they reason over is already in the
14
+ * prompt; a retrieval surface would only let them ground an answer in
15
+ * something the run never collected.
16
+ * - **critique** is a real agent loop (`runToTerminal` over `runAgent`) with a
17
+ * literature tool and a `defineTool` terminal recorder. Disconfirmation is a
18
+ * search task: the dossier is assembled to state what the run found, not to
19
+ * refute it, so a critic confined to it can only observe that the evidence
20
+ * is thin — which is the rubber stamp this step exists to avoid.
21
+ *
22
+ * Nothing here scores a claim. The verdict is a four-word vocabulary the model
23
+ * states, no threshold decides survival, and no claim is removed from the
24
+ * dossier by anything this phase concludes.
25
+ *
26
+ * Durability arrives through the injected `runStep` seam and through
27
+ * `structuredLlmCall`'s own attempt-named steps; this module imports no
28
+ * durability engine.
29
+ */
30
+ import { ok } from "neverthrow";
31
+ import { z } from "zod";
32
+ import { composeSystemPrompt } from "../../../agents/system-prompt.js";
33
+ import { createNoopLogger } from "../../../lib/console-logger.js";
34
+ import { runToTerminal } from "../../../loop/run-to-terminal.js";
35
+ import { adversarialCritiquePrompt, claimReverificationBrief, mechanismProposalBrief } from "../../../prompts/target-assessment/investigation/index.js";
36
+ import { defineTool } from "../../../tools/define-tool.js";
37
+ import { forSubAgent } from "../../../auth/types.js";
38
+ import { structuredLlmCall } from "../lib/structured-llm.js";
39
+ export const DEFAULT_CLAIM_INVESTIGATION_CONFIG = {
40
+ claimBudget: 6,
41
+ roundBound: 2,
42
+ criticMaxIterations: 8,
43
+ };
44
+ function isBudgetCancel(value) {
45
+ return typeof value === "object" && value !== null && "kind" in value && value.kind === "budget-exceeded";
46
+ }
47
+ // ── The model-facing claim contract ──────────────────────────────────
48
+ /**
49
+ * What a step is asked to return as support.
50
+ *
51
+ * Structurally the claim contract, minus the locator refinement — a refinement
52
+ * cannot be expressed in a tool input schema, so requiring it here would mean
53
+ * requiring it in prose and hoping. {@link resolveSupport} enforces it instead,
54
+ * at this producer's own boundary.
55
+ */
56
+ const ModelEvidenceSchema = z.object({
57
+ source: z.string().min(1).describe("Which corpus or record set the evidence came from."),
58
+ pmid: z.string().optional().describe("PubMed identifier, digits only, of a record you actually read."),
59
+ doi: z.string().optional().describe("Digital object identifier of a record you actually read."),
60
+ accession: z.string().optional().describe("Database accession of a record you actually read."),
61
+ regulatory_reference: z
62
+ .object({
63
+ document: z.string().min(1).describe("The regulatory document, e.g. an NDA/BLA application number."),
64
+ section: z.string().optional().describe("The label section the statement came from."),
65
+ })
66
+ .optional()
67
+ .describe("Set only when the evidence is a regulatory document you were shown or retrieved."),
68
+ excerpt: z.string().optional().describe("Short quotation or paraphrase of what the record says."),
69
+ });
70
+ const ModelSupportSchema = z.discriminatedUnion("state", [
71
+ z.object({
72
+ state: z.literal("scored"),
73
+ evidence: z
74
+ .array(ModelEvidenceSchema)
75
+ .min(1)
76
+ .describe("Records supporting the claim. Each needs a pmid, doi, accession, or regulatory reference you retrieved."),
77
+ }),
78
+ z.object({
79
+ state: z.literal("unknown"),
80
+ reason: z.string().min(1).describe("One line saying why no record supports the claim. A complete, expected answer."),
81
+ }),
82
+ ]);
83
+ /**
84
+ * Resolve model-supplied support onto the claim contract.
85
+ *
86
+ * Evidence naming only a source resolves to nothing a reader can check, so it is
87
+ * dropped; a scored claim left with no admissible evidence becomes `unknown`
88
+ * with a reason saying so. Degrading here rather than failing keeps a
89
+ * hallucinated citation from taking the whole assessment down at schema
90
+ * validation, and keeps the honest answer cheaper than the invented one.
91
+ */
92
+ function resolveSupport(support) {
93
+ if (support.state === "unknown")
94
+ return { state: "unknown", reason: support.reason };
95
+ const admissible = support.evidence.filter((e) => Boolean(e.pmid ?? e.doi ?? e.accession ?? e.regulatory_reference));
96
+ if (admissible.length === 0) {
97
+ return { state: "unknown", reason: "the cited support named a source but carried no publication identifier, doi, accession, or regulatory reference" };
98
+ }
99
+ return { state: "scored", evidence: admissible };
100
+ }
101
+ // ── Step output schemas ──────────────────────────────────────────────
102
+ const MechanismProposalOutputSchema = z.object({
103
+ mechanism: z.string().nullable().describe("How the target produces the liability in this organ. Null when the evidence supports none."),
104
+ support: ModelSupportSchema,
105
+ });
106
+ const CritiqueOutputSchema = z.object({
107
+ objection: z.string().min(1).describe("The concrete case against the claim. Not 'the evidence is limited'."),
108
+ support: ModelSupportSchema,
109
+ });
110
+ const ReverificationOutputSchema = z.object({
111
+ verdict: z.enum(["upheld", "weakened", "overturned", "undetermined"]).describe("What the record supports once the objection is weighed."),
112
+ support: ModelSupportSchema,
113
+ });
114
+ // ── Prompt payloads ──────────────────────────────────────────────────
115
+ /**
116
+ * What a step is shown about the organ under investigation.
117
+ *
118
+ * The corroboration row already carries every contributing source, its signal,
119
+ * and the record it came from, so the claim's whole evidential basis travels as
120
+ * one value. The rollup row adds the assessment's own per-organ risk read.
121
+ */
122
+ function claimContext(row, dossier) {
123
+ const rollup = dossier.safety_profile.organ_rollup;
124
+ const rollupRow = rollup.coverage === "available" ? rollup.data.rows.find((r) => r.organ === row.organ) : undefined;
125
+ return JSON.stringify({
126
+ target: dossier.entity.symbol,
127
+ organ: row.organ,
128
+ corroboration: {
129
+ corroborating_sources: row.corroborating_sources,
130
+ independent_source_count: row.independent_source_count,
131
+ contributions: row.contributions,
132
+ },
133
+ organ_rollup: rollupRow ?? null,
134
+ }, null, 2);
135
+ }
136
+ const PROPOSER_AGENT_ID = "claim-mechanism-proposer";
137
+ const CRITIC_AGENT_ID = "claim-adversarial-critic";
138
+ const VERIFIER_AGENT_ID = "claim-reverifier";
139
+ async function proposeMechanism(row, dossier, round, priorObjection, { deps }) {
140
+ const prompt = [
141
+ `Propose a mechanism for the ${row.organ} liability of ${dossier.entity.symbol}.`,
142
+ "",
143
+ claimContext(row, dossier),
144
+ ...(priorObjection ? ["", "A previous round raised this objection. Answer it or revise the mechanism:", priorObjection] : []),
145
+ ].join("\n");
146
+ const result = await structuredLlmCall({
147
+ ...(deps.logger ? { logger: deps.logger } : {}),
148
+ stepName: `ta-investigate:propose:${row.organ}:${deps.attempt}:${round}`,
149
+ agentId: PROPOSER_AGENT_ID,
150
+ provider: deps.chatProvider,
151
+ session: forSubAgent(deps.session, PROPOSER_AGENT_ID),
152
+ system: composeSystemPrompt(mechanismProposalBrief),
153
+ prompt,
154
+ schema: MechanismProposalOutputSchema,
155
+ model: deps.model,
156
+ ...(deps.signal ? { signal: deps.signal } : {}),
157
+ });
158
+ if (result.kind === "budget-exceeded")
159
+ return result;
160
+ const out = result.value;
161
+ if (out.mechanism === null || out.mechanism.trim().length === 0)
162
+ return { kind: "ok", value: null };
163
+ return { kind: "ok", value: { statement: out.mechanism, support: resolveSupport(out.support) } };
164
+ }
165
+ /**
166
+ * Namespace the critic loop's own step names.
167
+ *
168
+ * `runAgent` names its steps `llm-{i}` / `tool-{name}-{id}`, which are unique
169
+ * only within one run. Several claims are investigated concurrently and each
170
+ * runs several rounds, so without this every one of them would write into the
171
+ * same durable cache slots and replay another claim's answers.
172
+ */
173
+ function criticStepNames(organ, attempt, round) {
174
+ const prefix = `ta-investigate:critique:${organ}:${attempt}:${round}`;
175
+ return {
176
+ llm: (i) => `${prefix}:llm-${i}`,
177
+ tool: (name, id) => `${prefix}:tool-${name}-${id}`,
178
+ };
179
+ }
180
+ async function critiqueClaim(row, dossier, mechanism, round, { deps, config }) {
181
+ // A mutable holder rather than a plain `let`: the cell is written from the
182
+ // tool's `execute` closure, which the compiler cannot see, so a bare local
183
+ // would still read as `null` at every use after the loop returns.
184
+ const cell = { recorded: null };
185
+ const recordCritique = defineTool({
186
+ id: "record_critique",
187
+ description: "Record your case against the claim and finish. Call exactly once, with the strongest objection you " +
188
+ "could build and the counter-evidence you actually retrieved. If you found no record that disconfirms " +
189
+ "the claim, record the objection with support state 'unknown' and a one-line reason — that is a " +
190
+ "complete answer, not a failure.",
191
+ inputSchema: CritiqueOutputSchema,
192
+ describeCall: ({ objection }) => objection,
193
+ executionMode: "inline",
194
+ execute: (input) => {
195
+ cell.recorded = input;
196
+ return Promise.resolve(ok({ recorded: true }));
197
+ },
198
+ });
199
+ const agent = {
200
+ id: CRITIC_AGENT_ID,
201
+ systemPrompt: composeSystemPrompt(adversarialCritiquePrompt),
202
+ model: deps.model,
203
+ tools: [...deps.critiqueTools, recordCritique],
204
+ maxIterations: config.criticMaxIterations,
205
+ };
206
+ const brief = [
207
+ `Argue that the ${row.organ} liability claim about ${dossier.entity.symbol} does not hold.`,
208
+ "",
209
+ "Proposed mechanism:",
210
+ mechanism.statement,
211
+ "",
212
+ "Evidence the assessment collected for this organ:",
213
+ claimContext(row, dossier),
214
+ ].join("\n");
215
+ const controller = new AbortController();
216
+ await runToTerminal(agent, [{ role: "user", content: brief }], forSubAgent(deps.session, CRITIC_AGENT_ID), {
217
+ provider: deps.chatProvider,
218
+ signal: deps.signal ?? controller.signal,
219
+ emit: () => { },
220
+ runStep: deps.runStep,
221
+ formatStepName: criticStepNames(row.organ, deps.attempt, round),
222
+ resolved: () => cell.recorded !== null,
223
+ ...(deps.logger ? { logger: deps.logger } : {}),
224
+ ...(deps.usageRecorder ? { usageRecorder: deps.usageRecorder } : {}),
225
+ }, {
226
+ tools: [recordCritique],
227
+ nudge: "You have not recorded a critique. Call `record_critique` now with the case you built; if you found no disconfirming record, use support state 'unknown' with a one-line reason.",
228
+ });
229
+ const outcome = cell.recorded;
230
+ if (outcome === null)
231
+ return null;
232
+ return { objection: outcome.objection, support: resolveSupport(outcome.support) };
233
+ }
234
+ async function reverifyClaim(row, dossier, mechanism, critique, round, { deps }) {
235
+ const prompt = [
236
+ `Re-verify the ${row.organ} liability claim about ${dossier.entity.symbol}.`,
237
+ "",
238
+ "Proposed mechanism:",
239
+ mechanism.statement,
240
+ "",
241
+ "Objection raised against it:",
242
+ critique.objection,
243
+ "",
244
+ "Counter-evidence offered with the objection:",
245
+ JSON.stringify(critique.support, null, 2),
246
+ "",
247
+ "Evidence the assessment collected for this organ:",
248
+ claimContext(row, dossier),
249
+ ].join("\n");
250
+ const result = await structuredLlmCall({
251
+ ...(deps.logger ? { logger: deps.logger } : {}),
252
+ stepName: `ta-investigate:reverify:${row.organ}:${deps.attempt}:${round}`,
253
+ agentId: VERIFIER_AGENT_ID,
254
+ provider: deps.chatProvider,
255
+ session: forSubAgent(deps.session, VERIFIER_AGENT_ID),
256
+ system: composeSystemPrompt(claimReverificationBrief),
257
+ prompt,
258
+ schema: ReverificationOutputSchema,
259
+ model: deps.model,
260
+ ...(deps.signal ? { signal: deps.signal } : {}),
261
+ });
262
+ if (result.kind === "budget-exceeded")
263
+ return result;
264
+ return { kind: "ok", value: { verdict: result.value.verdict, support: resolveSupport(result.value.support) } };
265
+ }
266
+ /**
267
+ * Investigate one organ claim to convergence.
268
+ *
269
+ * The loop stops on the first of three conditions: a terminal verdict, a verdict
270
+ * that repeats the previous round's, or the configured round bound. Critiques
271
+ * are keyed by organ, so a later round's objection replaces the earlier one it
272
+ * was written in answer to.
273
+ */
274
+ async function investigateOneClaim(row, dossier, stepDeps) {
275
+ const logger = (stepDeps.deps.logger ?? createNoopLogger()).named("ta-claim-investigation").with({ organ: row.organ });
276
+ let mechanism = null;
277
+ let critique = null;
278
+ let verdict = null;
279
+ let previousVerdict = null;
280
+ let support = { state: "unknown", reason: "the investigation recorded no verdict support" };
281
+ let convergence = "round_bound_reached";
282
+ let roundsRun = 0;
283
+ for (let round = 1; round <= stepDeps.config.roundBound; round += 1) {
284
+ roundsRun = round;
285
+ let proposed;
286
+ try {
287
+ proposed = await proposeMechanism(row, dossier, round, critique?.objection ?? null, stepDeps);
288
+ }
289
+ catch (err) {
290
+ logger.warn("mechanism proposal failed", { round, ...logger.errorFields(err) });
291
+ return { kind: "unavailable", organ: row.organ, detail: `mechanism proposal failed: ${messageOf(err)}` };
292
+ }
293
+ if (proposed.kind === "budget-exceeded")
294
+ return proposed;
295
+ if (proposed.value === null) {
296
+ return { kind: "unavailable", organ: row.organ, detail: "no mechanism was proposable from the evidence collected for this organ" };
297
+ }
298
+ mechanism = proposed.value;
299
+ let rounded;
300
+ try {
301
+ rounded = await critiqueClaim(row, dossier, mechanism, round, stepDeps);
302
+ }
303
+ catch (err) {
304
+ logger.warn("adversarial critique failed", { round, ...logger.errorFields(err) });
305
+ return { kind: "unavailable", organ: row.organ, detail: `adversarial critique failed: ${messageOf(err)}` };
306
+ }
307
+ if (rounded === null) {
308
+ // A verdict reached without the adversarial step is the rubber stamp
309
+ // this phase exists to avoid, so the claim goes uninvestigated rather
310
+ // than acquiring an unchallenged verdict.
311
+ return { kind: "unavailable", organ: row.organ, detail: "the adversarial critic recorded no objection" };
312
+ }
313
+ critique = rounded;
314
+ let verified;
315
+ try {
316
+ verified = await reverifyClaim(row, dossier, mechanism, critique, round, stepDeps);
317
+ }
318
+ catch (err) {
319
+ logger.warn("re-verification failed", { round, ...logger.errorFields(err) });
320
+ return { kind: "unavailable", organ: row.organ, detail: `re-verification failed: ${messageOf(err)}` };
321
+ }
322
+ if (verified.kind === "budget-exceeded")
323
+ return verified;
324
+ verdict = verified.value.verdict;
325
+ support = verified.value.support;
326
+ if (verdict === "upheld" || verdict === "overturned") {
327
+ convergence = "verdict_terminal";
328
+ break;
329
+ }
330
+ if (verdict === previousVerdict) {
331
+ convergence = "verdict_settled";
332
+ break;
333
+ }
334
+ previousVerdict = verdict;
335
+ }
336
+ if (verdict === null || mechanism === null) {
337
+ return { kind: "unavailable", organ: row.organ, detail: "the investigation completed no round" };
338
+ }
339
+ return {
340
+ kind: "investigated",
341
+ row: {
342
+ organ: row.organ,
343
+ mechanism,
344
+ critique,
345
+ verdict,
346
+ rounds_run: roundsRun,
347
+ convergence,
348
+ support,
349
+ },
350
+ };
351
+ }
352
+ function messageOf(err) {
353
+ return err instanceof Error ? err.message : String(err);
354
+ }
355
+ // ── The phase ────────────────────────────────────────────────────────
356
+ /**
357
+ * Interrogate the run's corroborated organ claims.
358
+ *
359
+ * Coverage is the honest account of what happened: `not_loaded` when there was
360
+ * no corroboration record to work from, `queried_no_data` when the fold produced
361
+ * no corroborated organ, and `available` once at least one candidate reached the
362
+ * phase — including when every one of them ended up in `not_investigated`, since
363
+ * that list is what the section has to say in that case.
364
+ */
365
+ export async function investigateClaims(input, deps) {
366
+ const config = { ...DEFAULT_CLAIM_INVESTIGATION_CONFIG, ...deps.config };
367
+ const bounds = { round_bound: config.roundBound, claim_budget: config.claimBudget };
368
+ if (input.corroboration.coverage === "not_loaded") {
369
+ return { coverage: "not_loaded", reason: "no corroboration record was assembled for this run" };
370
+ }
371
+ const candidates = input.corroboration.coverage === "available" ? input.corroboration.data.rows : [];
372
+ if (candidates.length === 0) {
373
+ return { coverage: "queried_no_data", error: { message: "no corroborated organ claim reached the investigation" } };
374
+ }
375
+ const admitted = candidates.slice(0, config.claimBudget);
376
+ const notInvestigated = candidates.slice(config.claimBudget).map((row) => ({
377
+ organ: row.organ,
378
+ reason: "exceeded_claim_budget",
379
+ detail: `the run investigates at most ${config.claimBudget} corroborated claims; this one ranked below that`,
380
+ }));
381
+ // Organs the assessment's own per-organ risk read carries, which the
382
+ // corroboration fold never admitted. They are candidates a reader would
383
+ // expect to see interrogated, so their absence is stated rather than left
384
+ // to be noticed.
385
+ const corroboratedOrgans = new Set(candidates.map((r) => r.organ));
386
+ const rollup = input.dossier.safety_profile.organ_rollup;
387
+ if (rollup.coverage === "available") {
388
+ for (const rollupRow of rollup.data.rows) {
389
+ if (corroboratedOrgans.has(rollupRow.organ))
390
+ continue;
391
+ notInvestigated.push({
392
+ organ: rollupRow.organ,
393
+ reason: "not_corroborated",
394
+ detail: "the per-organ risk rollup carries this organ, but fewer independent sources corroborated it than the fold requires",
395
+ });
396
+ }
397
+ }
398
+ const outcomes = await Promise.all(admitted.map((row) => investigateOneClaim(row, input.dossier, { deps, config })));
399
+ const cancelled = outcomes.find(isBudgetCancel);
400
+ if (cancelled)
401
+ return cancelled;
402
+ const rows = [];
403
+ for (const outcome of outcomes) {
404
+ if (outcome.kind === "investigated")
405
+ rows.push(outcome.row);
406
+ else if (outcome.kind === "unavailable") {
407
+ notInvestigated.push({ organ: outcome.organ, reason: "investigation_unavailable", detail: outcome.detail });
408
+ }
409
+ }
410
+ rows.sort((a, b) => a.organ.localeCompare(b.organ));
411
+ notInvestigated.sort((a, b) => a.organ.localeCompare(b.organ) || a.reason.localeCompare(b.reason));
412
+ const droppedByBudget = candidates.length - admitted.length;
413
+ return {
414
+ coverage: "available",
415
+ data: { rows, not_investigated: notInvestigated, ...bounds },
416
+ ...(droppedByBudget > 0 ? { dropped_count: droppedByBudget } : {}),
417
+ };
418
+ }
419
+ export { isBudgetCancel as isClaimInvestigationBudgetExceeded };
@@ -6,8 +6,18 @@
6
6
  * before synthesis so the fetched precedents can be injected into the synthesis
7
7
  * prompts as a static markdown block. The fetch, in-memory TTL cache, and
8
8
  * mapping mirror what a per-turn tool would have done, minus the tool wrapper.
9
+ *
10
+ * One fetch produces one typed form: each precedent keeps its label's safety
11
+ * sections as `LabelSafetyText`. The markdown block is a projection of that
12
+ * form, not a replacement for it — the same sections also project onto
13
+ * per-organ regulatory signals via `segmentLabelSafety`.
14
+ *
15
+ * The lookup is cached in-process through the shared `BoundedTtlCache`: two
16
+ * assessments in the same therapeutic area ask openFDA the same question, and
17
+ * the answer moves on the scale of a label revision, not a run.
9
18
  */
10
- import type { DossierV4Body } from "../../../contracts/target-dossier.js";
19
+ import type { DossierBody } from "../../../contracts/target-dossier.js";
20
+ import { type FdaLabelSafety, type LabelSafetyText } from "./fda-label-safety.js";
11
21
  export type PrecedentModality = "small_molecule" | "biologic" | "gene_therapy" | "cell_therapy";
12
22
  export interface FetchApprovalPrecedentsInput {
13
23
  indication: string;
@@ -15,11 +25,12 @@ export interface FetchApprovalPrecedentsInput {
15
25
  mechanism?: string;
16
26
  }
17
27
  export interface Precedent {
18
- application_number: string;
28
+ /** Null when openFDA published the label without one; such a label cannot be cited. */
29
+ application_number: string | null;
19
30
  brand_name?: string;
20
31
  generic_name?: string;
21
32
  approval_date?: string;
22
- label_section_excerpts?: Record<string, string>;
33
+ safety_sections: LabelSafetyText[];
23
34
  }
24
35
  /**
25
36
  * Query openFDA for prior approvals in a given indication. Returns
@@ -34,18 +45,28 @@ export interface Precedent {
34
45
  export declare function fetchApprovalPrecedents(input: FetchApprovalPrecedentsInput): Promise<{
35
46
  precedents: Precedent[];
36
47
  }>;
37
- /** Test-only — clears the in-memory cache to avoid cross-test bleed. */
48
+ /** Test-only — drops the in-memory cache to avoid cross-test bleed. */
38
49
  export declare function __resetApprovalPrecedentCacheForTest(): void;
39
50
  /**
40
51
  * Pick the openFDA query term for a Phase-4 dossier: the top `indications`
41
52
  * row by `composite_score` (only when indications coverage is available and
42
53
  * has rows), falling back to the inferred therapeutic area, else null.
43
54
  */
44
- export declare function pickIndicationForPrecedents(dossier: DossierV4Body): string | null;
55
+ export declare function pickIndicationForPrecedents(dossier: DossierBody): string | null;
56
+ /**
57
+ * Project the fetched precedents onto the typed label form the per-organ
58
+ * segmentation reads. A label without an application number is skipped: its
59
+ * signals would carry no locator, and an unciteable signal is not evidence.
60
+ */
61
+ export declare function precedentLabelSafety(precedents: readonly Precedent[]): FdaLabelSafety[];
45
62
  /**
46
63
  * Render the fetched precedents as a markdown block for injection into the
47
64
  * synthesis prompts. The block always begins with the `## FDA approval
48
65
  * precedents` header, which the synthesis briefs reference verbatim.
66
+ *
67
+ * Synthesis is a single-shot forced-`submit` call that can reach no tool, so
68
+ * this block is the whole of what the model gets: a plain string, no citation
69
+ * the model is expected to resolve itself.
49
70
  */
50
71
  export declare function renderApprovalPrecedents(indication: string | null, result: {
51
72
  precedents: Precedent[];
@@ -6,9 +6,50 @@
6
6
  * before synthesis so the fetched precedents can be injected into the synthesis
7
7
  * prompts as a static markdown block. The fetch, in-memory TTL cache, and
8
8
  * mapping mirror what a per-turn tool would have done, minus the tool wrapper.
9
+ *
10
+ * One fetch produces one typed form: each precedent keeps its label's safety
11
+ * sections as `LabelSafetyText`. The markdown block is a projection of that
12
+ * form, not a replacement for it — the same sections also project onto
13
+ * per-organ regulatory signals via `segmentLabelSafety`.
14
+ *
15
+ * The lookup is cached in-process through the shared `BoundedTtlCache`: two
16
+ * assessments in the same therapeutic area ask openFDA the same question, and
17
+ * the answer moves on the scale of a label revision, not a run.
9
18
  */
10
- const cache = new Map();
19
+ import { BoundedTtlCache } from "../../../lib/cache.js";
20
+ import { LABEL_SAFETY_SECTIONS } from "./fda-label-safety.js";
21
+ /** Per-block ingest budget — enough prose to segment, bounded for the step cache. */
22
+ const SECTION_INGEST_CHARS = 4000;
23
+ /** Per-section budget in the rendered markdown, which rides in every synthesis prompt. */
24
+ const SECTION_RENDER_CHARS = 1000;
25
+ function ingestSafetySections(r) {
26
+ const sections = [];
27
+ const push = (section, blocks) => {
28
+ for (const block of blocks ?? []) {
29
+ const text = block.trim();
30
+ if (text.length === 0)
31
+ continue;
32
+ sections.push({ section, text: text.slice(0, SECTION_INGEST_CHARS) });
33
+ }
34
+ };
35
+ push("boxed_warning", r.boxed_warning);
36
+ // Current labels publish Section 5 as `warnings_and_precautions`; older ones
37
+ // publish the same prose as `warnings`. Ingesting both files it twice.
38
+ if (r.warnings_and_precautions?.length)
39
+ push("warnings_and_precautions", r.warnings_and_precautions);
40
+ else
41
+ push("warnings", r.warnings);
42
+ push("contraindications", r.contraindications);
43
+ return sections;
44
+ }
11
45
  const TTL_MS = 60 * 60 * 1000;
46
+ /**
47
+ * One entry per (indication, modality, mechanism) an assessment resolves, and a
48
+ * host serves many assessments from one process — hence a bound, not a Map that
49
+ * only ever grows.
50
+ */
51
+ const CACHE_MAXIMUM = 64;
52
+ let cache = new BoundedTtlCache(CACHE_MAXIMUM);
12
53
  /**
13
54
  * Query openFDA for prior approvals in a given indication. Returns
14
55
  * NDA/BLA application numbers, generic and brand names, approval dates
@@ -27,8 +68,8 @@ export async function fetchApprovalPrecedents(input) {
27
68
  mechanism,
28
69
  });
29
70
  const cached = cache.get(key);
30
- if (cached && Date.now() - cached.ts < TTL_MS)
31
- return cached.value;
71
+ if (cached)
72
+ return cached;
32
73
  const term = indication.replace(/"/g, "");
33
74
  const url = new URL("https://api.fda.gov/drug/label.json");
34
75
  url.searchParams.set("search", `indications_and_usage:"${term}"`);
@@ -40,31 +81,20 @@ export async function fetchApprovalPrecedents(input) {
40
81
  throw new Error(`openFDA label query failed: HTTP ${res.status}`);
41
82
  }
42
83
  const json = (await res.json());
43
- const precedents = (json.results ?? []).slice(0, 10).map((r) => {
44
- const excerpts = {};
45
- if (r.boxed_warning?.[0])
46
- excerpts.boxed_warning = r.boxed_warning[0].slice(0, 1000);
47
- if (r.warnings_and_precautions?.[0])
48
- excerpts.warnings_and_precautions = r.warnings_and_precautions[0].slice(0, 1000);
49
- else if (r.warnings?.[0])
50
- excerpts.warnings = r.warnings[0].slice(0, 1000);
51
- if (r.contraindications?.[0])
52
- excerpts.contraindications = r.contraindications[0].slice(0, 1000);
53
- return {
54
- application_number: r.openfda?.application_number?.[0] ?? "(unknown)",
55
- brand_name: r.openfda?.brand_name?.[0],
56
- generic_name: r.openfda?.generic_name?.[0],
57
- approval_date: r.effective_time,
58
- label_section_excerpts: Object.keys(excerpts).length > 0 ? excerpts : undefined,
59
- };
60
- });
84
+ const precedents = (json.results ?? []).slice(0, 10).map((r) => ({
85
+ application_number: r.openfda?.application_number?.[0] ?? null,
86
+ brand_name: r.openfda?.brand_name?.[0],
87
+ generic_name: r.openfda?.generic_name?.[0],
88
+ approval_date: r.effective_time,
89
+ safety_sections: ingestSafetySections(r),
90
+ }));
61
91
  const value = { precedents };
62
- cache.set(key, { ts: Date.now(), value });
92
+ cache.set(key, value, TTL_MS);
63
93
  return value;
64
94
  }
65
- /** Test-only — clears the in-memory cache to avoid cross-test bleed. */
95
+ /** Test-only — drops the in-memory cache to avoid cross-test bleed. */
66
96
  export function __resetApprovalPrecedentCacheForTest() {
67
- cache.clear();
97
+ cache = new BoundedTtlCache(CACHE_MAXIMUM);
68
98
  }
69
99
  /**
70
100
  * Pick the openFDA query term for a Phase-4 dossier: the top `indications`
@@ -80,10 +110,47 @@ export function pickIndicationForPrecedents(dossier) {
80
110
  }
81
111
  return dossier.liability_summary.inferred_therapeutic_area ?? null;
82
112
  }
113
+ /**
114
+ * Project the fetched precedents onto the typed label form the per-organ
115
+ * segmentation reads. A label without an application number is skipped: its
116
+ * signals would carry no locator, and an unciteable signal is not evidence.
117
+ */
118
+ export function precedentLabelSafety(precedents) {
119
+ const labels = [];
120
+ for (const p of precedents) {
121
+ if (p.application_number === null || p.safety_sections.length === 0)
122
+ continue;
123
+ labels.push({
124
+ application_number: p.application_number,
125
+ drug_name: p.generic_name ?? p.brand_name ?? p.application_number,
126
+ effective_time: p.approval_date,
127
+ sections: p.safety_sections,
128
+ });
129
+ }
130
+ return labels;
131
+ }
132
+ /**
133
+ * The prose a reader meets for one section: the longest block published under
134
+ * it. openFDA returns both the highlights summary and the full section under
135
+ * the same key, and the full section is the one worth grounding on.
136
+ */
137
+ function renderableSections(precedent) {
138
+ const longest = new Map();
139
+ for (const { section, text } of precedent.safety_sections) {
140
+ const current = longest.get(section);
141
+ if (current === undefined || text.length > current.length)
142
+ longest.set(section, text);
143
+ }
144
+ return LABEL_SAFETY_SECTIONS.filter((section) => longest.has(section)).map((section) => [section, longest.get(section).slice(0, SECTION_RENDER_CHARS)]);
145
+ }
83
146
  /**
84
147
  * Render the fetched precedents as a markdown block for injection into the
85
148
  * synthesis prompts. The block always begins with the `## FDA approval
86
149
  * precedents` header, which the synthesis briefs reference verbatim.
150
+ *
151
+ * Synthesis is a single-shot forced-`submit` call that can reach no tool, so
152
+ * this block is the whole of what the model gets: a plain string, no citation
153
+ * the model is expected to resolve itself.
87
154
  */
88
155
  export function renderApprovalPrecedents(indication, result) {
89
156
  const header = "## FDA approval precedents";
@@ -108,11 +175,10 @@ export function renderApprovalPrecedents(indication, result) {
108
175
  const generic = p.generic_name ?? "unknown generic";
109
176
  const brand = p.brand_name ?? "unknown brand";
110
177
  const date = p.approval_date ?? "unknown date";
111
- lines.push(`- ${generic} (${brand}), ${p.application_number}, approved ${date}`);
112
- if (p.label_section_excerpts) {
113
- for (const [section, excerpt] of Object.entries(p.label_section_excerpts)) {
114
- lines.push(` - ${section}: ${excerpt}`);
115
- }
178
+ const application = p.application_number ?? "unknown application number";
179
+ lines.push(`- ${generic} (${brand}), ${application}, approved ${date}`);
180
+ for (const [section, excerpt] of renderableSections(p)) {
181
+ lines.push(` - ${section}: ${excerpt}`);
116
182
  }
117
183
  }
118
184
  return lines.join("\n");
@@ -280,7 +280,7 @@ export function validateRecommendationCitations(rec, dossier) {
280
280
  }
281
281
  }
282
282
  }
283
- const organLiabilities = dossierView?.safety_profile?.target_organ_liabilities ?? [];
283
+ const organLiabilities = dossierView?.safety_profile?.target_organ_liabilities?.data?.rows ?? [];
284
284
  const tolOrgans = new Set(organLiabilities
285
285
  .map((t) => String(t?.organ ?? "")
286
286
  .toLowerCase()