@inflexa-ai/harness 0.17.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.
- package/dist/agents/report-builder.d.ts +2 -0
- package/dist/agents/report-builder.js +2 -0
- package/dist/app/spawn-report-session.d.ts +85 -0
- package/dist/app/spawn-report-session.js +83 -0
- package/dist/contracts/chat-events.d.ts +9 -0
- package/dist/contracts/index.d.ts +6 -2
- package/dist/contracts/index.js +3 -1
- package/dist/contracts/organ-system.d.ts +49 -0
- package/dist/contracts/organ-system.js +67 -0
- package/dist/contracts/report-blocks.d.ts +564 -0
- package/dist/contracts/report-blocks.js +117 -0
- package/dist/contracts/report-reference.d.ts +459 -0
- package/dist/contracts/report-reference.js +256 -0
- package/dist/contracts/schemas/chat-events.d.ts +2 -0
- package/dist/contracts/schemas/chat-events.js +2 -0
- package/dist/contracts/severity.d.ts +15 -0
- package/dist/contracts/severity.js +10 -0
- package/dist/contracts/target-dossier.d.ts +8675 -12972
- package/dist/contracts/target-dossier.js +534 -591
- package/dist/data/safety-panel-schema.d.ts +18 -5
- package/dist/data/safety-panel-schema.js +4 -4
- package/dist/execution/artifact-metadata.js +1 -0
- package/dist/execution/report-runner.js +1 -3
- package/dist/execution/run-synthesis.js +2 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +19 -0
- package/dist/lib/async-utils.d.ts +16 -4
- package/dist/lib/async-utils.js +20 -15
- package/dist/lib/host-concurrency.js +1 -0
- package/dist/loop/__fixtures__/scripted-provider.js +23 -2
- package/dist/loop/run-agent.js +58 -10
- package/dist/loop/tool-detail.d.ts +40 -0
- package/dist/loop/tool-detail.js +35 -3
- package/dist/loop/types.d.ts +13 -0
- package/dist/memory/thread-history.d.ts +10 -0
- package/dist/memory/thread-history.js +12 -1
- package/dist/prompts/target-assessment/briefs/executive-recommendation.d.ts +1 -1
- package/dist/prompts/target-assessment/briefs/executive-recommendation.js +5 -5
- package/dist/prompts/target-assessment/briefs/liability-bullets.d.ts +1 -1
- package/dist/prompts/target-assessment/briefs/liability-bullets.js +1 -1
- package/dist/prompts/target-assessment/briefs/target-organ-liabilities.d.ts +1 -1
- package/dist/prompts/target-assessment/briefs/target-organ-liabilities.js +11 -3
- package/dist/prompts/target-assessment/investigation/adversarial-critique.d.ts +1 -0
- package/dist/prompts/target-assessment/investigation/adversarial-critique.js +67 -0
- package/dist/prompts/target-assessment/investigation/claim-reverification.d.ts +1 -0
- package/dist/prompts/target-assessment/investigation/claim-reverification.js +49 -0
- package/dist/prompts/target-assessment/investigation/index.d.ts +3 -0
- package/dist/prompts/target-assessment/investigation/index.js +3 -0
- package/dist/prompts/target-assessment/investigation/mechanism-proposal.d.ts +1 -0
- package/dist/prompts/target-assessment/investigation/mechanism-proposal.js +49 -0
- package/dist/prompts/target-assessment/tox-voice/index.d.ts +1 -1
- package/dist/prompts/target-assessment/tox-voice/vocabulary.d.ts +8 -5
- package/dist/prompts/target-assessment/tox-voice/vocabulary.js +9 -20
- package/dist/providers/__fixtures__/session.d.ts +5 -4
- package/dist/providers/__fixtures__/session.js +4 -4
- package/dist/report-model/block-walk.d.ts +56 -0
- package/dist/report-model/block-walk.js +89 -0
- package/dist/report-model/draft-finish.d.ts +60 -0
- package/dist/report-model/draft-finish.js +56 -0
- package/dist/report-model/draft-operations.d.ts +125 -0
- package/dist/report-model/draft-operations.js +341 -0
- package/dist/report-model/draft-read.d.ts +59 -0
- package/dist/report-model/draft-read.js +109 -0
- package/dist/report-model/draft.d.ts +51 -0
- package/dist/report-model/draft.js +32 -0
- package/dist/report-model/fixture-resolver.d.ts +8 -0
- package/dist/report-model/fixture-resolver.js +252 -0
- package/dist/report-model/mint-snapshot.d.ts +30 -0
- package/dist/report-model/mint-snapshot.js +40 -0
- package/dist/report-model/reference-resolver.d.ts +113 -0
- package/dist/report-model/reference-resolver.js +60 -0
- package/dist/report-model/structural-validation.d.ts +22 -0
- package/dist/report-model/structural-validation.js +68 -0
- package/dist/report-model/validate.d.ts +40 -0
- package/dist/report-model/validate.js +80 -0
- package/dist/report-render/chart.d.ts +33 -0
- package/dist/report-render/chart.js +538 -0
- package/dist/report-render/page.d.ts +127 -0
- package/dist/report-render/page.js +307 -0
- package/dist/report-render/references.d.ts +32 -0
- package/dist/report-render/references.js +44 -0
- package/dist/report-render/render.d.ts +25 -0
- package/dist/report-render/render.js +125 -0
- package/dist/report-render/script-json.d.ts +13 -0
- package/dist/report-render/script-json.js +15 -0
- package/dist/report-render/types.d.ts +39 -0
- package/dist/report-render/types.js +10 -0
- package/dist/report-render/views/chart-view.d.ts +11 -0
- package/dist/report-render/views/chart-view.js +23 -0
- package/dist/report-render/views/page-view.d.ts +20 -0
- package/dist/report-render/views/page-view.js +49 -0
- package/dist/report-render/views/prose.d.ts +28 -0
- package/dist/report-render/views/prose.js +75 -0
- package/dist/report-render/views/references-view.d.ts +20 -0
- package/dist/report-render/views/references-view.js +64 -0
- package/dist/report-render/views/values.d.ts +42 -0
- package/dist/report-render/views/values.js +43 -0
- package/dist/runtime/boot.d.ts +3 -3
- package/dist/runtime/boot.js +15 -4
- package/dist/runtime/dbos.d.ts +4 -4
- package/dist/state/artifacts.d.ts +16 -0
- package/dist/state/artifacts.js +18 -0
- package/dist/state/init.js +31 -0
- package/dist/state/purge-analysis.js +1 -0
- package/dist/state/report-versions.d.ts +132 -0
- package/dist/state/report-versions.js +194 -0
- package/dist/tasks/data-profile.js +1 -0
- package/dist/tools/bio/chembl.js +1 -0
- package/dist/tools/bio/comptox.js +1 -0
- package/dist/tools/bio/drug-gene-interactions.js +1 -0
- package/dist/tools/bio/gene-disease-evidence.js +1 -0
- package/dist/tools/bio/gene-preclinical-profile.js +1 -0
- package/dist/tools/bio/keys.d.ts +3 -2
- package/dist/tools/bio/keys.js +3 -2
- package/dist/tools/bio/lookup-annotation.js +1 -0
- package/dist/tools/bio/opentargets.d.ts +3 -3
- package/dist/tools/bio/opentargets.js +4 -3
- package/dist/tools/bio/pubchem.js +1 -0
- package/dist/tools/bio/search-clinical-trials.js +1 -0
- package/dist/tools/bio/search-faers.js +1 -0
- package/dist/tools/bio/search-geo-datasets.js +1 -0
- package/dist/tools/bio/search-interactions.js +1 -0
- package/dist/tools/bio/target-safety.d.ts +4 -6
- package/dist/tools/bio/target-safety.js +7 -6
- package/dist/tools/define-tool.d.ts +19 -12
- package/dist/tools/define-tool.js +4 -2
- package/dist/tools/display/show-user.js +1 -0
- package/dist/tools/iterate-report.js +2 -0
- package/dist/tools/lib/chembl-client.d.ts +2 -0
- package/dist/tools/lib/chembl-client.js +1 -0
- package/dist/tools/lib/monarch-client.d.ts +42 -0
- package/dist/tools/lib/monarch-client.js +100 -0
- package/dist/tools/report/build-report.js +1 -0
- package/dist/tools/report/mint-preview-url.js +1 -0
- package/dist/tools/report/preview-snapshot.js +1 -0
- package/dist/tools/report/submit-report.js +1 -0
- package/dist/tools/report/version-fs.js +4 -0
- package/dist/tools/report-authoring/authoring-tools.d.ts +134 -0
- package/dist/tools/report-authoring/authoring-tools.js +352 -0
- package/dist/tools/research/context7-docs.js +2 -0
- package/dist/tools/research/generate-analogy-report.js +1 -0
- package/dist/tools/research/generate-plan.js +3 -0
- package/dist/tools/research/inspect-data-profile.js +4 -0
- package/dist/tools/research/literature-reviewer.js +1 -0
- package/dist/tools/research/search-arxiv.js +1 -0
- package/dist/tools/research/search-github-repos.js +1 -0
- package/dist/tools/research/search-semantic-scholar.js +1 -0
- package/dist/tools/sandbox/list-available-packages.js +23 -0
- package/dist/tools/sandbox/list-available-refs.js +36 -0
- package/dist/tools/sandbox/report-blocker.js +1 -0
- package/dist/tools/sandbox/skills.js +2 -0
- package/dist/tools/workspace/file-stat.js +1 -0
- package/dist/tools/workspace/show-file.js +2 -0
- package/dist/tools/workspace/show-plan.js +1 -0
- package/dist/workflows/execute-analysis.d.ts +5 -1
- package/dist/workflows/execute-analysis.js +5 -5
- package/dist/workflows/execute-target-assessment.d.ts +13 -2
- package/dist/workflows/execute-target-assessment.js +78 -16
- package/dist/workflows/target-assessment/assemblers/index.d.ts +1 -1
- package/dist/workflows/target-assessment/assemblers/index.js +1 -1
- package/dist/workflows/target-assessment/assemblers/literature.d.ts +9 -27
- package/dist/workflows/target-assessment/assemblers/literature.js +32 -48
- package/dist/workflows/target-assessment/assemblers/orchestrator.d.ts +17 -9
- package/dist/workflows/target-assessment/assemblers/orchestrator.js +195 -85
- package/dist/workflows/target-assessment/assemblers/safety.d.ts +70 -48
- package/dist/workflows/target-assessment/assemblers/safety.js +125 -42
- package/dist/workflows/target-assessment/collectors/index.d.ts +5 -2
- package/dist/workflows/target-assessment/collectors/index.js +48 -5
- package/dist/workflows/target-assessment/coverage.d.ts +43 -27
- package/dist/workflows/target-assessment/coverage.js +33 -29
- package/dist/workflows/target-assessment/fanout/index.js +1 -0
- package/dist/workflows/target-assessment/investigation/index.d.ts +97 -0
- package/dist/workflows/target-assessment/investigation/index.js +419 -0
- package/dist/workflows/target-assessment/lib/approval-precedents.d.ts +26 -5
- package/dist/workflows/target-assessment/lib/approval-precedents.js +95 -29
- package/dist/workflows/target-assessment/lib/citation-validator.js +1 -1
- package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.d.ts +2 -2
- package/dist/workflows/target-assessment/lib/clinical-evidence-attribution.d.ts +2 -3
- package/dist/workflows/target-assessment/lib/clinical-evidence-attribution.js +0 -4
- package/dist/workflows/target-assessment/lib/compute-derived.d.ts +6 -5
- package/dist/workflows/target-assessment/lib/compute-derived.js +14 -5
- package/dist/workflows/target-assessment/lib/family-complex-supplement.d.ts +5 -6
- package/dist/workflows/target-assessment/lib/family-complex-supplement.js +2 -3
- package/dist/workflows/target-assessment/lib/fda-label-safety.d.ts +72 -0
- package/dist/workflows/target-assessment/lib/fda-label-safety.js +166 -0
- package/dist/workflows/target-assessment/lib/heterodimer-filter.d.ts +0 -9
- package/dist/workflows/target-assessment/lib/heterodimer-filter.js +0 -10
- package/dist/workflows/target-assessment/lib/hpo-organ-map.d.ts +34 -0
- package/dist/workflows/target-assessment/lib/hpo-organ-map.js +63 -0
- package/dist/workflows/target-assessment/lib/impc-organ-map.d.ts +19 -0
- package/dist/workflows/target-assessment/lib/impc-organ-map.js +39 -0
- package/dist/workflows/target-assessment/lib/meddra-organ-map.d.ts +24 -14
- package/dist/workflows/target-assessment/lib/meddra-organ-map.js +59 -47
- package/dist/workflows/target-assessment/lib/recommendation-sanitizer.js +1 -1
- package/dist/workflows/target-assessment/lib/regulatory-actions.d.ts +2 -3
- package/dist/workflows/target-assessment/lib/regulatory-actions.js +33 -4
- package/dist/workflows/target-assessment/lib/safety-corroboration.d.ts +44 -0
- package/dist/workflows/target-assessment/lib/safety-corroboration.js +273 -0
- package/dist/workflows/target-assessment/lib/target-identity-filter.d.ts +5 -7
- package/dist/workflows/target-assessment/lib/target-identity-filter.js +5 -7
- package/dist/workflows/target-assessment/metrics.d.ts +1 -6
- package/dist/workflows/target-assessment/metrics.js +1 -13
- package/dist/workflows/target-assessment/phase4-assemble.d.ts +2147 -807
- package/dist/workflows/target-assessment/phase4-assemble.js +19 -19
- package/dist/workflows/target-assessment/phase5-persist.d.ts +20 -21
- package/dist/workflows/target-assessment/phase5-persist.js +56 -173
- package/dist/workflows/target-assessment/schemas.d.ts +46 -1
- package/dist/workflows/target-assessment/schemas.js +19 -0
- package/dist/workflows/target-assessment/steps/fanout/polypharm-for-one-modulator-step.d.ts +1 -0
- package/dist/workflows/target-assessment/steps/fanout/polypharm-for-one-modulator-step.js +2 -0
- package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +26 -62
- package/dist/workflows/target-assessment/steps/phase2-aggregate.js +0 -4
- package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +27 -154
- package/dist/workflows/target-assessment/steps/phase3-aggregate.js +0 -6
- package/dist/workflows/target-assessment/steps/synthesis/diagnostics.d.ts +0 -6
- package/dist/workflows/target-assessment/steps/synthesis/diagnostics.js +0 -18
- package/dist/workflows/target-assessment/sub-workflows/per-modulator-polypharm.d.ts +1 -0
- package/dist/workflows/target-assessment/synthesis/index.d.ts +52 -5
- package/dist/workflows/target-assessment/synthesis/index.js +4 -1
- package/dist/workflows/target-assessment/synthesis-probes.d.ts +3 -3
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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:
|
|
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
|
-
|
|
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
|
|
31
|
-
return cached
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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,
|
|
92
|
+
cache.set(key, value, TTL_MS);
|
|
63
93
|
return value;
|
|
64
94
|
}
|
|
65
|
-
/** Test-only —
|
|
95
|
+
/** Test-only — drops the in-memory cache to avoid cross-test bleed. */
|
|
66
96
|
export function __resetApprovalPrecedentCacheForTest() {
|
|
67
|
-
cache
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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()
|