@inflexa-ai/harness 0.1.1 → 0.2.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/conversation-agent.d.ts +12 -11
- package/dist/agents/conversation-agent.js +30 -33
- package/dist/agents/report-builder.js +2 -1
- package/dist/agents/sandbox/bulk-transcriptomics-agent.js +1 -1
- package/dist/agents/sandbox/cheminformatics-agent.js +1 -19
- package/dist/agents/sandbox/chromatin-agent.js +1 -10
- package/dist/agents/sandbox/dna-methylation-agent.js +1 -10
- package/dist/agents/sandbox/drug-repurposing-agent.js +4 -14
- package/dist/agents/sandbox/enrichment-agent.js +1 -10
- package/dist/agents/sandbox/ephemeral-executor.js +1 -10
- package/dist/agents/sandbox/genomic-variant-agent.js +1 -3
- package/dist/agents/sandbox/immune-profiling-agent.js +1 -13
- package/dist/agents/sandbox/metabolomics-agent.js +1 -1
- package/dist/agents/sandbox/microbiome-agent.js +1 -1
- package/dist/agents/sandbox/multi-omics-integration-agent.js +1 -12
- package/dist/agents/sandbox/multimodal-sc-agent.js +1 -1
- package/dist/agents/sandbox/network-agent.js +1 -10
- package/dist/agents/sandbox/pkpd-clinical-response-agent.js +2 -8
- package/dist/agents/sandbox/proteomics-agent.js +1 -1
- package/dist/agents/sandbox/scientific-executor.js +1 -10
- package/dist/agents/sandbox/shared.d.ts +17 -6
- package/dist/agents/sandbox/shared.js +49 -38
- package/dist/agents/sandbox/single-cell-agent.js +1 -1
- package/dist/agents/sandbox/spatial-omics-agent.js +1 -1
- package/dist/agents/sandbox/statistical-modeling-agent.js +1 -1
- package/dist/agents/sandbox/translational-safety-agent.js +3 -9
- package/dist/agents/sandbox/types.d.ts +1 -1
- package/dist/agents/system-prompt.d.ts +37 -11
- package/dist/agents/system-prompt.js +35 -12
- package/dist/app/data-profile-orientation.d.ts +34 -0
- package/dist/app/data-profile-orientation.js +117 -0
- package/dist/contracts/schemas/chat-parts.d.ts +9 -9
- package/dist/execution/artifact-metadata.js +2 -1
- package/dist/execution/ephemeral-runner.d.ts +13 -0
- package/dist/execution/ephemeral-runner.js +23 -1
- package/dist/execution/run-synthesis.js +20 -19
- package/dist/execution/step-summary.js +2 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +19 -5
- package/dist/loop/__fixtures__/scripted-provider.d.ts +2 -2
- package/dist/loop/__fixtures__/scripted-provider.js +2 -1
- package/dist/loop/metrics.d.ts +33 -1
- package/dist/loop/metrics.js +56 -2
- package/dist/loop/run-agent.d.ts +13 -1
- package/dist/loop/run-agent.js +19 -4
- package/dist/memory/card-builders.d.ts +8 -1
- package/dist/memory/card-builders.js +20 -3
- package/dist/memory/reconstruct-cards.js +4 -3
- package/dist/memory/thread-history.d.ts +27 -2
- package/dist/memory/thread-history.js +45 -5
- package/dist/memory/working-memory.d.ts +93 -10
- package/dist/memory/working-memory.js +220 -84
- package/dist/prompts/SOUL.d.ts +22 -9
- package/dist/prompts/SOUL.js +56 -37
- package/dist/prompts/analogical-reasoner.d.ts +1 -1
- package/dist/prompts/analogical-reasoner.js +2 -2
- package/dist/prompts/briefing.d.ts +100 -0
- package/dist/prompts/briefing.js +166 -0
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +94 -440
- package/dist/prompts/literature-reviewer.d.ts +1 -1
- package/dist/prompts/literature-reviewer.js +10 -10
- package/dist/prompts/planner.js +28 -59
- package/dist/prompts/report-builder.d.ts +1 -1
- package/dist/prompts/report-builder.js +71 -24
- package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +2 -7
- package/dist/prompts/sandbox/cheminformatics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/cheminformatics-agent.js +11 -15
- package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
- package/dist/prompts/sandbox/chromatin-agent.js +3 -7
- package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
- package/dist/prompts/sandbox/data-profiler.js +13 -8
- package/dist/prompts/sandbox/dna-methylation-agent.d.ts +1 -1
- package/dist/prompts/sandbox/dna-methylation-agent.js +2 -7
- package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
- package/dist/prompts/sandbox/drug-repurposing-agent.js +15 -16
- package/dist/prompts/sandbox/enrichment-agent.d.ts +1 -1
- package/dist/prompts/sandbox/enrichment-agent.js +2 -9
- package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
- package/dist/prompts/sandbox/genomic-variant-agent.js +2 -6
- package/dist/prompts/sandbox/immune-profiling-agent.d.ts +1 -1
- package/dist/prompts/sandbox/immune-profiling-agent.js +3 -8
- package/dist/prompts/sandbox/metabolomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/metabolomics-agent.js +1 -7
- package/dist/prompts/sandbox/microbiome-agent.d.ts +1 -1
- package/dist/prompts/sandbox/microbiome-agent.js +2 -7
- package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multi-omics-integration-agent.js +2 -8
- package/dist/prompts/sandbox/multimodal-sc-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multimodal-sc-agent.js +2 -7
- package/dist/prompts/sandbox/network-agent.d.ts +1 -1
- package/dist/prompts/sandbox/network-agent.js +2 -7
- package/dist/prompts/sandbox/proteomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/proteomics-agent.js +2 -9
- package/dist/prompts/sandbox/single-cell-agent.d.ts +1 -1
- package/dist/prompts/sandbox/single-cell-agent.js +2 -9
- package/dist/prompts/sandbox/spatial-omics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/spatial-omics-agent.js +3 -6
- package/dist/prompts/sandbox/statistical-modeling-agent.d.ts +1 -1
- package/dist/prompts/sandbox/statistical-modeling-agent.js +2 -7
- package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
- package/dist/prompts/sandbox/translational-safety-agent.js +13 -14
- package/dist/prompts/sandbox-standards.d.ts +22 -14
- package/dist/prompts/sandbox-standards.js +91 -71
- package/dist/prompts/synthesis-agent.d.ts +1 -1
- package/dist/prompts/synthesis-agent.js +33 -131
- package/dist/provenance/collector.js +1 -1
- package/dist/providers/ai-sdk.js +31 -4
- package/dist/providers/prompt-cache.d.ts +74 -0
- package/dist/providers/prompt-cache.js +77 -0
- package/dist/providers/types.d.ts +33 -0
- package/dist/reference-data/catalog.d.ts +131 -0
- package/dist/reference-data/catalog.js +348 -0
- package/dist/reference-data/receipt.d.ts +41 -0
- package/dist/reference-data/receipt.js +40 -0
- package/dist/schemas/data-profile-schemas.js +5 -3
- package/dist/schemas/plan-schemas.d.ts +21 -21
- package/dist/schemas/plan-schemas.js +9 -3
- package/dist/schemas/run-synthesis.d.ts +8 -4
- package/dist/schemas/run-synthesis.js +92 -56
- package/dist/schemas/validate-plan.js +1 -1
- package/dist/schemas/workflow-state.d.ts +7 -1
- package/dist/schemas/workflow-state.js +33 -19
- package/dist/state/artifacts.d.ts +13 -0
- package/dist/state/artifacts.js +27 -0
- package/dist/state/data-profile.d.ts +64 -5
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +1 -1
- package/dist/tasks/data-profile.d.ts +20 -2
- package/dist/tasks/data-profile.js +71 -22
- package/dist/tools/bio/chembl.d.ts +38 -0
- package/dist/tools/bio/chembl.js +138 -0
- package/dist/tools/bio/comptox.d.ts +50 -0
- package/dist/tools/bio/comptox.js +149 -0
- package/dist/tools/bio/get-impc-ko-profile.js +9 -6
- package/dist/tools/bio/index.d.ts +5 -17
- package/dist/tools/bio/index.js +5 -17
- package/dist/tools/bio/keys.d.ts +14 -9
- package/dist/tools/bio/keys.js +16 -16
- package/dist/tools/bio/opentargets.d.ts +38 -0
- package/dist/tools/bio/opentargets.js +89 -0
- package/dist/tools/bio/pubchem.d.ts +29 -0
- package/dist/tools/bio/pubchem.js +92 -0
- package/dist/tools/bio/pubmed.d.ts +51 -0
- package/dist/tools/bio/pubmed.js +100 -0
- package/dist/tools/bio/search-bgee-expression.js +12 -6
- package/dist/tools/bio/search-clinical-trials.js +16 -5
- package/dist/tools/bio/search-faers.js +8 -4
- package/dist/tools/bio/search-geo-datasets.js +20 -6
- package/dist/tools/bio/search-gwas-catalog.d.ts +1 -1
- package/dist/tools/bio/search-pharmgkb.js +10 -4
- package/dist/tools/display/index.d.ts +1 -0
- package/dist/tools/display/index.js +1 -0
- package/dist/tools/display/normalize-echart-spec.d.ts +61 -0
- package/dist/tools/display/normalize-echart-spec.js +211 -0
- package/dist/tools/display/show-user.d.ts +6 -1
- package/dist/tools/display/show-user.js +25 -12
- package/dist/tools/execute-plan.js +5 -2
- package/dist/tools/iterate-report.d.ts +168 -118
- package/dist/tools/iterate-report.js +334 -96
- package/dist/tools/lib/chembl-client.d.ts +12 -0
- package/dist/tools/lib/chembl-client.js +127 -0
- package/dist/tools/lib/ctx-ops.d.ts +224 -0
- package/dist/tools/lib/ctx-ops.js +450 -0
- package/dist/tools/lib/iuphar-client.js +10 -1
- package/dist/tools/lib/openfda-client.js +11 -3
- package/dist/tools/lib/pathway-client.js +10 -4
- package/dist/tools/lib/pubchem-ops.d.ts +74 -0
- package/dist/tools/lib/pubchem-ops.js +224 -0
- package/dist/tools/memory/update-working-memory.d.ts +45 -7
- package/dist/tools/memory/update-working-memory.js +111 -41
- package/dist/tools/report/index.d.ts +3 -2
- package/dist/tools/report/index.js +3 -2
- package/dist/tools/research/generate-analogy-report.js +25 -12
- package/dist/tools/research/generate-plan.d.ts +12 -5
- package/dist/tools/research/generate-plan.js +168 -37
- package/dist/tools/research/index.d.ts +1 -0
- package/dist/tools/research/index.js +1 -0
- package/dist/tools/research/inspect-data-profile.d.ts +85 -0
- package/dist/tools/research/inspect-data-profile.js +161 -0
- package/dist/tools/research/literature-reviewer.js +3 -4
- package/dist/tools/sandbox/list-available-packages.d.ts +79 -5
- package/dist/tools/sandbox/list-available-packages.js +152 -10
- package/dist/tools/sandbox/list-available-refs.d.ts +141 -5
- package/dist/tools/sandbox/list-available-refs.js +647 -134
- package/dist/tools/sandbox/report-blocker.d.ts +39 -9
- package/dist/tools/sandbox/report-blocker.js +43 -20
- package/dist/tools/workspace/read-file.js +4 -5
- package/dist/tools/workspace/run-exec.d.ts +4 -4
- package/dist/tools/workspace/run-exec.js +4 -4
- package/dist/tools/workspace/show-file.js +10 -6
- package/dist/tools/workspace/show-plan.js +12 -6
- package/dist/tools/workspace/workspace-search.d.ts +1 -1
- package/dist/tools/workspace/workspace-search.js +11 -5
- package/dist/workflows/execute-analysis.d.ts +43 -4
- package/dist/workflows/execute-analysis.js +107 -6
- package/dist/workflows/target-assessment/lib/citation-validator.js +2 -2
- package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.js +2 -1
- package/dist/workspace/paths.d.ts +2 -2
- package/dist/workspace/paths.js +2 -2
- package/package.json +33 -33
- package/dist/execution/filesystem-artifact-registry.d.ts +0 -57
- package/dist/execution/filesystem-artifact-registry.js +0 -92
- package/dist/lib/identifiers.d.ts +0 -8
- package/dist/lib/identifiers.js +0 -10
- package/dist/lib/mime-detect.d.ts +0 -17
- package/dist/lib/mime-detect.js +0 -29
- package/dist/lib/streamed-download.d.ts +0 -28
- package/dist/lib/streamed-download.js +0 -111
- package/dist/lib/trace-context.d.ts +0 -11
- package/dist/lib/trace-context.js +0 -49
- package/dist/providers/llm-capabilities.d.ts +0 -33
- package/dist/providers/llm-capabilities.js +0 -58
- package/dist/schemas/render-step-prompt.d.ts +0 -32
- package/dist/schemas/render-step-prompt.js +0 -76
- package/dist/tools/bio/find-approval-precedent.d.ts +0 -32
- package/dist/tools/bio/find-approval-precedent.js +0 -72
- package/dist/tools/bio/get-article-details.d.ts +0 -11
- package/dist/tools/bio/get-article-details.js +0 -20
- package/dist/tools/bio/get-article-full-text.d.ts +0 -19
- package/dist/tools/bio/get-article-full-text.js +0 -32
- package/dist/tools/bio/get-bioactivity.d.ts +0 -11
- package/dist/tools/bio/get-bioactivity.js +0 -23
- package/dist/tools/bio/get-drug-info.d.ts +0 -9
- package/dist/tools/bio/get-drug-info.js +0 -22
- package/dist/tools/bio/get-mechanism.d.ts +0 -8
- package/dist/tools/bio/get-mechanism.js +0 -18
- package/dist/tools/bio/get-pubchem-assays.d.ts +0 -22
- package/dist/tools/bio/get-pubchem-assays.js +0 -107
- package/dist/tools/bio/get-pubchem-cross-refs.d.ts +0 -15
- package/dist/tools/bio/get-pubchem-cross-refs.js +0 -68
- package/dist/tools/bio/get-target-safety.d.ts +0 -18
- package/dist/tools/bio/get-target-safety.js +0 -26
- package/dist/tools/bio/search-compounds.d.ts +0 -22
- package/dist/tools/bio/search-compounds.js +0 -160
- package/dist/tools/bio/search-ctx-chemical.d.ts +0 -50
- package/dist/tools/bio/search-ctx-chemical.js +0 -130
- package/dist/tools/bio/search-ctx-exposure.d.ts +0 -55
- package/dist/tools/bio/search-ctx-exposure.js +0 -176
- package/dist/tools/bio/search-ctx-hazard.d.ts +0 -111
- package/dist/tools/bio/search-ctx-hazard.js +0 -178
- package/dist/tools/bio/search-opentargets.d.ts +0 -11
- package/dist/tools/bio/search-opentargets.js +0 -25
- package/dist/tools/bio/search-pubchem-compound.d.ts +0 -26
- package/dist/tools/bio/search-pubchem-compound.js +0 -110
- package/dist/tools/bio/search-pubmed.d.ts +0 -17
- package/dist/tools/bio/search-pubmed.js +0 -31
- package/dist/tools/bio/search-targets.d.ts +0 -9
- package/dist/tools/bio/search-targets.js +0 -20
- package/dist/tools/bio/search-toxcast.d.ts +0 -36
- package/dist/tools/bio/search-toxcast.js +0 -141
- package/dist/workflows/register-workflows.d.ts +0 -51
- package/dist/workflows/register-workflows.js +0 -48
- package/dist/workflows/resume-execute-analysis.d.ts +0 -70
- package/dist/workflows/resume-execute-analysis.js +0 -80
- package/dist/workflows/target-assessment/correction-loop.d.ts +0 -45
- package/dist/workflows/target-assessment/correction-loop.js +0 -62
- package/dist/workflows/target-assessment/steps/phase0-resolve.d.ts +0 -71
- package/dist/workflows/target-assessment/steps/phase0-resolve.js +0 -21
- package/dist/workflows/target-assessment/steps/phase1-aggregate.d.ts +0 -1018
- package/dist/workflows/target-assessment/steps/phase1-aggregate.js +0 -25
- package/dist/workspace/mount-strategy.d.ts +0 -50
- package/dist/workspace/mount-strategy.js +0 -67
- package/dist/workspace/provenance-collector.d.ts +0 -34
- package/dist/workspace/provenance-collector.js +0 -16
- package/dist/workspace/workspace-paths.d.ts +0 -26
- package/dist/workspace/workspace-paths.js +0 -31
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pubmed — one tool over PubMed and PubMed Central (NCBI E-utilities):
|
|
3
|
+
* search the literature, fetch article metadata, and read the full text of an
|
|
4
|
+
* open-access article.
|
|
5
|
+
*
|
|
6
|
+
* Dependency-bearing: the optional NCBI API key is captured by the factory.
|
|
7
|
+
*
|
|
8
|
+
* The input is a flat object with an `action` discriminator — not a
|
|
9
|
+
* `z.discriminatedUnion`, which `defineTool` rejects (model tool calling needs
|
|
10
|
+
* a top-level `"type":"object"`). Each action's required argument is optional
|
|
11
|
+
* in the schema and enforced by `.refine`, so a malformed call (e.g. 'fulltext'
|
|
12
|
+
* with no pmcId) fails at the loop boundary and surfaces as an `is_error` tool
|
|
13
|
+
* result instead of reaching NCBI.
|
|
14
|
+
*
|
|
15
|
+
* The three actions form one retrieval chain — search yields PMIDs, details
|
|
16
|
+
* turns PMIDs into abstracts + the PMC id, fulltext turns a PMC id into the
|
|
17
|
+
* article body — which is why they share a tool: the identifier for each step
|
|
18
|
+
* is read off the previous step's result.
|
|
19
|
+
*/
|
|
20
|
+
import { ok } from "neverthrow";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { defineTool } from "../define-tool.js";
|
|
23
|
+
import { getArticleDetails, getArticleFullText, searchPubmed } from "../lib/pubmed-client.js";
|
|
24
|
+
const inputSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
action: z
|
|
27
|
+
.enum(["search", "details", "fulltext"])
|
|
28
|
+
.describe("'search' (needs query) — returns totalFound plus, per hit, PMID, title, journal, year and an author summary. " +
|
|
29
|
+
"'details' (needs pmids, max 20 per call) — each article's full abstract, authors, journal, year, DOI, MeSH terms, and " +
|
|
30
|
+
"pmcId (non-null only when open-access full text exists in PubMed Central). " +
|
|
31
|
+
"'fulltext' (needs pmcId, taken from a 'details' result) — the body of ONE open-access PMC article as plain text with " +
|
|
32
|
+
"section headers preserved; available: false when the article is not open-access."),
|
|
33
|
+
query: z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("Required for action 'search'. PubMed query syntax — MeSH terms (\"breast neoplasms\"[MeSH]), field tags " +
|
|
37
|
+
'([Title/Abstract], [Author], [Gene]), Boolean AND/OR/NOT. Example: "BRCA1"[Gene] AND "drug resistance"[Title/Abstract].'),
|
|
38
|
+
maxResults: z.number().int().min(1).max(50).default(10).optional().describe("action 'search': max results, 1–50 (default 10)."),
|
|
39
|
+
sort: z.enum(["relevance", "date"]).default("relevance").optional().describe("action 'search': sort order (default \"relevance\")."),
|
|
40
|
+
dateRange: z
|
|
41
|
+
.object({
|
|
42
|
+
from: z.string().describe("Start date in YYYY/MM/DD format"),
|
|
43
|
+
to: z.string().describe("End date in YYYY/MM/DD format"),
|
|
44
|
+
})
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("action 'search': optional publication date range filter."),
|
|
47
|
+
pmids: z
|
|
48
|
+
.array(z.string())
|
|
49
|
+
.min(1)
|
|
50
|
+
.max(20)
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("Required for action 'details'. Non-empty array of PMID strings, max 20 — batch them, do not call once per PMID."),
|
|
53
|
+
pmcId: z.string().optional().describe("Required for action 'fulltext'. A PMC ID (e.g. \"PMC1234567\") copied from a 'details' result — not a PMID."),
|
|
54
|
+
})
|
|
55
|
+
.refine((d) => d.action !== "search" || (d.query !== undefined && d.query.trim().length > 0), {
|
|
56
|
+
message: "query is required when action is 'search'",
|
|
57
|
+
path: ["query"],
|
|
58
|
+
})
|
|
59
|
+
.refine((d) => d.action !== "details" || (d.pmids !== undefined && d.pmids.length > 0), {
|
|
60
|
+
message: "pmids is required when action is 'details' — pass a non-empty array of PMID strings (max 20), copied from a 'search' result",
|
|
61
|
+
path: ["pmids"],
|
|
62
|
+
})
|
|
63
|
+
.refine((d) => d.action !== "fulltext" || (d.pmcId !== undefined && d.pmcId.trim().length > 0), {
|
|
64
|
+
message: "pmcId is required when action is 'fulltext' — copy it from the pmcId field of a 'details' result; a PMID is not a PMC ID",
|
|
65
|
+
path: ["pmcId"],
|
|
66
|
+
});
|
|
67
|
+
export function createPubMedTool(deps) {
|
|
68
|
+
return defineTool({
|
|
69
|
+
id: "pubmed",
|
|
70
|
+
description: "Search and read the biomedical literature via PubMed / PubMed Central (NCBI E-utilities). The three actions form a chain " +
|
|
71
|
+
"— search, then details on the relevant hits, then fulltext; see the action parameter for what each needs and returns. Read " +
|
|
72
|
+
"fulltext sparingly — only where the 'details' abstract is not enough, and only for articles that have a pmcId (open-access). " +
|
|
73
|
+
"available: false is an expected outcome, not an error — do not retry it.",
|
|
74
|
+
inputSchema,
|
|
75
|
+
execute: async (input) => {
|
|
76
|
+
switch (input.action) {
|
|
77
|
+
case "search":
|
|
78
|
+
return ok(await searchPubmed(deps.ncbiApiKey, input.query, {
|
|
79
|
+
maxResults: input.maxResults,
|
|
80
|
+
sort: input.sort,
|
|
81
|
+
dateRange: input.dateRange,
|
|
82
|
+
}));
|
|
83
|
+
case "details":
|
|
84
|
+
return ok(await getArticleDetails(deps.ncbiApiKey, input.pmids));
|
|
85
|
+
case "fulltext": {
|
|
86
|
+
const parsed = await getArticleFullText(deps.ncbiApiKey, input.pmcId);
|
|
87
|
+
// "Not open-access" is an expected outcome — a data variant, not an error.
|
|
88
|
+
if (!parsed)
|
|
89
|
+
return ok({ pmcId: input.pmcId, available: false });
|
|
90
|
+
return ok({
|
|
91
|
+
pmcId: input.pmcId,
|
|
92
|
+
available: true,
|
|
93
|
+
fullText: parsed.fullText,
|
|
94
|
+
sections: parsed.sections,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -8,17 +8,23 @@ import { SUPPORTED_SPECIES, bucketRank, getMultiSpeciesExpression, parseExpressi
|
|
|
8
8
|
export { SUPPORTED_SPECIES, bucketRank, parseExpressionResponse };
|
|
9
9
|
export const searchBgeeExpressionTool = defineTool({
|
|
10
10
|
id: "search_bgee_expression",
|
|
11
|
-
description: "Get
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
11
|
+
description: "Get BASELINE (healthy, untreated) gene expression across species from Bgee — use it to judge tissue-of-action ('where is this target expressed?') and model-organism " +
|
|
12
|
+
"suitability ('is the mouse a fair surrogate for this gene?'). " +
|
|
13
|
+
"NOT for differential expression between conditions — that is the analysis pipeline's job, never this tool. " +
|
|
14
|
+
"Takes a human gene symbol and resolves the per-species orthologs itself, returning a per-species tissue table: each tissue carries an expression score, a gold/silver confidence, " +
|
|
15
|
+
"an expressed / not-expressed state, and a bucketed rank (absent | low | medium | high). Species with no ortholog are listed in notFound. " +
|
|
16
|
+
"Sparse output is valid no-data: dog and macaque coverage is thin, and a null humanEnsemblId means the symbol did not resolve. Do NOT retry on empty output.",
|
|
15
17
|
inputSchema: z.object({
|
|
16
|
-
geneSymbol: z
|
|
18
|
+
geneSymbol: z
|
|
19
|
+
.string()
|
|
20
|
+
.min(1)
|
|
21
|
+
.describe("HUMAN gene symbol, e.g. 'BRCA1' — one gene per call. Ensembl IDs and non-human symbols are not accepted; the human symbol is resolved to an ENSG and orthologs are looked up per species."),
|
|
17
22
|
species: z
|
|
18
23
|
.array(z.enum(SUPPORTED_SPECIES))
|
|
19
24
|
.min(1)
|
|
20
25
|
.default([...SUPPORTED_SPECIES])
|
|
21
|
-
.describe("Species to fetch
|
|
26
|
+
.describe("Species to fetch, any subset of: homo_sapiens, mus_musculus, rattus_norvegicus, canis_lupus_familiaris, macaca_mulatta. " +
|
|
27
|
+
"Defaults to all five — narrow it only when the cross-species comparison is not needed."),
|
|
22
28
|
}),
|
|
23
29
|
execute: async ({ geneSymbol, species }) => ok(await getMultiSpeciesExpression(geneSymbol, species)),
|
|
24
30
|
});
|
|
@@ -7,12 +7,23 @@ import { defineTool } from "../define-tool.js";
|
|
|
7
7
|
import { searchTrials } from "../lib/clinical-trials-client.js";
|
|
8
8
|
export const searchClinicalTrialsTool = defineTool({
|
|
9
9
|
id: "search_clinical_trials",
|
|
10
|
-
description: "Search ClinicalTrials.gov
|
|
10
|
+
description: "Search ClinicalTrials.gov to map the clinical development landscape for a target, drug, or indication — what is being tried, at what phase, by whom, and what stopped. " +
|
|
11
|
+
"Returns totalFound plus per trial: NCT ID, title, status, phase, conditions, interventions, enrollment, start and completion dates, sponsor, brief summary, and whyStopped " +
|
|
12
|
+
"(the termination reason, when there is one). " +
|
|
13
|
+
"`query` is free text matched across the whole study record, so a gene symbol, a drug name, a condition, or an NCT ID all work. " +
|
|
14
|
+
"`phase` and `status` are optional server-side filters — omit them to see the full landscape; totalFound reports the true match count even when it exceeds the page returned. " +
|
|
15
|
+
"An empty trials array is a valid 'nothing matched' (often an over-narrow filter) — do not retry the identical query.",
|
|
11
16
|
inputSchema: z.object({
|
|
12
|
-
query: z.string().describe("
|
|
13
|
-
phase: z
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
query: z.string().describe("Free-text search term: condition name, drug/intervention name, gene symbol, or an NCT ID."),
|
|
18
|
+
phase: z
|
|
19
|
+
.enum(["EARLY_PHASE1", "PHASE1", "PHASE2", "PHASE3", "PHASE4"])
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Optional exact phase filter. Omit for all phases (observational and expanded-access studies carry no phase and are excluded when this is set)."),
|
|
22
|
+
status: z
|
|
23
|
+
.enum(["RECRUITING", "ACTIVE_NOT_RECRUITING", "COMPLETED", "NOT_YET_RECRUITING"])
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("Optional recruitment-status filter. Omit for all statuses — note TERMINATED/WITHDRAWN trials are not selectable here and appear only in an unfiltered search."),
|
|
26
|
+
limit: z.number().int().min(1).max(50).default(20).describe("Max trials to return (default 20, max 50)."),
|
|
16
27
|
}),
|
|
17
28
|
execute: async ({ query, phase, status, limit = 20 }) => {
|
|
18
29
|
const result = await searchTrials(query, { phase, status, limit });
|
|
@@ -7,11 +7,15 @@ import { defineTool } from "../define-tool.js";
|
|
|
7
7
|
import { getFaersByDrug } from "../lib/openfda-client.js";
|
|
8
8
|
export const searchFaersTool = defineTool({
|
|
9
9
|
id: "search_faers",
|
|
10
|
-
description: "Search FDA FAERS
|
|
10
|
+
description: "Search FDA FAERS for post-market adverse-event reports on one marketed drug — the real-world safety signal for a specific molecule. " +
|
|
11
|
+
"Returns totalReports and adverseEvents[]: { reaction (MedDRA preferred term), count }, most-reported first. " +
|
|
12
|
+
"These are spontaneous report counts, NOT incidence rates: there is no denominator and reporting is heavily biased, so use them to rank signals, never to state a rate. " +
|
|
13
|
+
"Matching is on the openFDA GENERIC name only — 'imatinib' works, the brand name 'Gleevec' does not. For mechanism-based liabilities of a target (rather than a drug), use opentargets({action:\"safety\"}). " +
|
|
14
|
+
"An empty adverseEvents array is valid no-data (drug absent from FAERS under that generic name) — do not retry.",
|
|
11
15
|
inputSchema: z.object({
|
|
12
|
-
drugName: z.string().describe("
|
|
13
|
-
limit: z.number().int().min(1).max(100).default(25).describe("Max adverse
|
|
14
|
-
serious: z.boolean().default(false).describe("
|
|
16
|
+
drugName: z.string().describe("Generic (INN) drug name, e.g. 'imatinib', 'pembrolizumab'. Brand names do not match."),
|
|
17
|
+
limit: z.number().int().min(1).max(100).default(25).describe("Max distinct adverse-reaction terms to return (default 25, max 100)."),
|
|
18
|
+
serious: z.boolean().default(false).describe("When true, count only reports flagged serious (death, hospitalization, life-threatening, disabling)."),
|
|
15
19
|
}),
|
|
16
20
|
execute: async ({ drugName, limit = 25, serious = false }) => {
|
|
17
21
|
const result = await getFaersByDrug(drugName, { limit, serious });
|
|
@@ -38,17 +38,31 @@ const GeoEsummaryResponseSchema = z.object({
|
|
|
38
38
|
});
|
|
39
39
|
export const searchGeoDatasetsTool = defineTool({
|
|
40
40
|
id: "search_geo_datasets",
|
|
41
|
-
description: "Search NCBI GEO for public gene
|
|
41
|
+
description: "Search NCBI GEO for public gene-expression datasets by disease, tissue, or experimental condition — use it to find external validation cohorts or to cite published data. " +
|
|
42
|
+
"Returns totalFound plus datasets[]: { accession (GSE…/GDS…), title, summary (truncated to 500 chars), platform, sampleCount, organism, pubmedIds }. " +
|
|
43
|
+
"HARD CAVEAT: sandbox containers cannot download GEO data. This tool identifies and cites accessions only — never plan an analysis step that fetches a GEO dataset. " +
|
|
44
|
+
"An empty datasets array is a valid 'nothing matched' — do not retry the identical query.",
|
|
42
45
|
inputSchema: z.object({
|
|
43
|
-
query: z
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
query: z
|
|
47
|
+
.string()
|
|
48
|
+
.describe("NCBI Entrez free-text query (e.g. 'breast cancer RNA-seq responder', 'NSCLC pembrolizumab'). Entrez field tags are allowed."),
|
|
49
|
+
organism: z
|
|
50
|
+
.string()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("Optional organism filter, appended as an [Organism] term. Use the scientific name, e.g. 'Homo sapiens', 'Mus musculus'."),
|
|
53
|
+
datasetType: z
|
|
54
|
+
.enum(["gds", "gse"])
|
|
55
|
+
.default("gse")
|
|
56
|
+
.describe("'gse' (default) — GEO Series: raw submitter-deposited studies, far more numerous. 'gds' — curated DataSets: fewer, but normalized and value-added."),
|
|
57
|
+
limit: z.number().int().min(1).max(50).default(15).describe("Max datasets to return (default 15, max 50)."),
|
|
47
58
|
}),
|
|
48
59
|
execute: async ({ query, organism, datasetType = "gse", limit = 15 }) => {
|
|
49
60
|
let searchQuery = query;
|
|
61
|
+
// Escape backslashes first, then quotes, so a `\` in the organism value
|
|
62
|
+
// cannot smuggle an unescaped `"` past the escaping and break out of the
|
|
63
|
+
// quoted Entrez phrase.
|
|
50
64
|
if (organism)
|
|
51
|
-
searchQuery += ` AND "${organism.replace(/"/g, '\\"')}"[Organism]`;
|
|
65
|
+
searchQuery += ` AND "${organism.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"[Organism]`;
|
|
52
66
|
const db = "gds";
|
|
53
67
|
const typeFilter = datasetType === "gse" ? " AND gse[Entry Type]" : " AND gds[Entry Type]";
|
|
54
68
|
searchQuery += typeFilter;
|
|
@@ -23,7 +23,7 @@ interface GwasAssociation {
|
|
|
23
23
|
}
|
|
24
24
|
export declare const searchGwasCatalogTool: import("../define-tool.js").Tool<{
|
|
25
25
|
query: string;
|
|
26
|
-
searchType: "gene" | "
|
|
26
|
+
searchType: "gene" | "variant" | "trait";
|
|
27
27
|
pValueThreshold: number;
|
|
28
28
|
limit: number;
|
|
29
29
|
}, {
|
|
@@ -20,11 +20,17 @@ const PharmgkbResponseSchema = z.object({
|
|
|
20
20
|
});
|
|
21
21
|
export const searchPharmgkbTool = defineTool({
|
|
22
22
|
id: "search_pharmgkb",
|
|
23
|
-
description: "Search PharmGKB
|
|
23
|
+
description: "Search PharmGKB clinical annotations for a gene-drug pharmacogenomic link — use it to judge whether a gene affects a drug's metabolism, efficacy, or toxicity. " +
|
|
24
|
+
"Returns annotations[]: { gene, drug, levelOfEvidence (PharmGKB 1A…4, where 1A is guideline-backed) }. The phenotype, guidelineSource and summary fields are always null " +
|
|
25
|
+
"in this tool's output — treat levelOfEvidence as the only strength signal and do not attribute CPIC/DPWG guideline text to it. " +
|
|
26
|
+
"Matching is an exact field filter, not a search: 'CYP2D6' resolves, 'cytochrome P450 2D6' does not. " +
|
|
27
|
+
"An empty annotations array is valid no-data (no curated PGx link) — do not retry.",
|
|
24
28
|
inputSchema: z.object({
|
|
25
|
-
query: z
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
query: z
|
|
30
|
+
.string()
|
|
31
|
+
.describe("Exact gene symbol (e.g. 'CYP2D6') when searchType='gene', or exact drug name (e.g. 'tamoxifen') when searchType='drug'. No fuzzy matching."),
|
|
32
|
+
searchType: z.enum(["gene", "drug"]).describe("'gene' filters PharmGKB on location.genes.symbol; 'drug' filters on relatedChemicals.name."),
|
|
33
|
+
limit: z.number().int().min(1).max(50).default(20).describe("Max annotations to return (default 20, max 50); applied client-side after the fetch."),
|
|
28
34
|
}),
|
|
29
35
|
execute: async ({ query, searchType, limit = 20 }) => {
|
|
30
36
|
const filter = searchType === "gene" ? `location.genes.symbol=${encodeURIComponent(query)}` : `relatedChemicals.name=${encodeURIComponent(query)}`;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECharts layout normalization — the renderer-side invariant behind `show_user(kind: "echart")`.
|
|
3
|
+
*
|
|
4
|
+
* The layout discipline (no duplicate title, bottom legend, grid margins that fit the axis labels,
|
|
5
|
+
* every category label shown, a downloadable chart) is applied HERE, deterministically, rather than
|
|
6
|
+
* being a checklist the model must remember on every chart it composes. A rule a model must remember
|
|
7
|
+
* is a rule it can forget.
|
|
8
|
+
*
|
|
9
|
+
* Two invariants govern every rule below:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Defaults, not overrides.** Every property is filled in only when the author left it unset —
|
|
12
|
+
* an explicit, sensible authored `grid`/`legend`/`rotate`/filename is respected. The two forced
|
|
13
|
+
* fields are `title` (deleted: the `show_user` `title` param is the card heading, so an in-spec
|
|
14
|
+
* title is a genuine duplicate render) and `axisLabel.interval` (pinned to `0`: ECharts' default
|
|
15
|
+
* `"auto"` SILENTLY DROPS labels, which is the exact bug this rule exists to prevent).
|
|
16
|
+
* 2. **Never turn a valid chart invalid.** Nothing here is derived from data that may be absent: a
|
|
17
|
+
* property is only injected where it is meaningful (a `grid` only for a cartesian chart), and an
|
|
18
|
+
* unknown category count yields the safe layout rather than a guessed one.
|
|
19
|
+
*
|
|
20
|
+
* Pure: the input spec is never mutated. Modified branches are copy-on-write; untouched branches are
|
|
21
|
+
* shared by reference. `normalize(normalize(x))` deep-equals `normalize(x)`.
|
|
22
|
+
*/
|
|
23
|
+
export interface NormalizeEchartSpecOptions {
|
|
24
|
+
/**
|
|
25
|
+
* The `show_user` `title` param — the card heading rendered above the chart canvas. Seeds the
|
|
26
|
+
* `saveAsImage` download filename when the author supplied none.
|
|
27
|
+
*/
|
|
28
|
+
title?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Kebab-case download filename: `"HMGCR — VST Expression"` → `"hmgcr-vst-expression"`.
|
|
32
|
+
* Diacritics are folded, every other non-alphanumeric run collapses to a single dash.
|
|
33
|
+
*/
|
|
34
|
+
export declare function kebabFilename(source: string | undefined): string;
|
|
35
|
+
/**
|
|
36
|
+
* Category count on the x axis, or `null` when the spec cannot tell us.
|
|
37
|
+
*
|
|
38
|
+
* `null` is the honest answer for a CSV-backed chart (`show_user`'s `dataPath`): the rows are loaded
|
|
39
|
+
* by the host at render time and deliberately never pulled through the model's context window, so
|
|
40
|
+
* they are NOT in the spec. Guessing a rotation for an axis of unknown length is worse than not
|
|
41
|
+
* rotating — an unrotated axis is also exactly what the other `null` cases (a value axis: scatter,
|
|
42
|
+
* time series) want. The count is used ONLY to pick a label rotation; it never touches the data, so
|
|
43
|
+
* an off-by-one at a bucket boundary costs at most a rotation, never a rendering.
|
|
44
|
+
*/
|
|
45
|
+
export declare function categoryCount(spec: Record<string, unknown>): number | null;
|
|
46
|
+
/**
|
|
47
|
+
* Apply the ECharts layout invariant to an author-composed spec.
|
|
48
|
+
*
|
|
49
|
+
* - `title` is deleted (always) — the `show_user` `title` param is the visible card heading, and
|
|
50
|
+
* `title.subtext` is the documented misuse for stats that belong in the tooltip.
|
|
51
|
+
* - `legend` defaults to `{ bottom: 0 }` for a multi-series chart and `{ show: false }` for a
|
|
52
|
+
* single-series one (nothing to disambiguate); an authored legend is left alone.
|
|
53
|
+
* - `grid` (cartesian charts only) gets `top` 8% — 12% when a `graphic` annotation shares the canvas
|
|
54
|
+
* — `bottom` 20% / 25% (rotated labels) / 30% (rotated labels above a bottom legend), `left` 10%,
|
|
55
|
+
* `right` 5%. Only unset keys are filled.
|
|
56
|
+
* - `axisLabel.interval` is pinned to `0` on every axis; the x axis is rotated 45° (11–20 categories)
|
|
57
|
+
* or 90° (>20) unless the author chose a rotation.
|
|
58
|
+
* - `toolbox.feature.saveAsImage` is injected with `type: "png"` and a kebab-case filename derived
|
|
59
|
+
* from the `show_user` title (falling back to the stripped in-spec title, then `"chart"`).
|
|
60
|
+
*/
|
|
61
|
+
export declare function normalizeEchartSpec(spec: Record<string, unknown>, opts?: NormalizeEchartSpecOptions): Record<string, unknown>;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECharts layout normalization — the renderer-side invariant behind `show_user(kind: "echart")`.
|
|
3
|
+
*
|
|
4
|
+
* The layout discipline (no duplicate title, bottom legend, grid margins that fit the axis labels,
|
|
5
|
+
* every category label shown, a downloadable chart) is applied HERE, deterministically, rather than
|
|
6
|
+
* being a checklist the model must remember on every chart it composes. A rule a model must remember
|
|
7
|
+
* is a rule it can forget.
|
|
8
|
+
*
|
|
9
|
+
* Two invariants govern every rule below:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Defaults, not overrides.** Every property is filled in only when the author left it unset —
|
|
12
|
+
* an explicit, sensible authored `grid`/`legend`/`rotate`/filename is respected. The two forced
|
|
13
|
+
* fields are `title` (deleted: the `show_user` `title` param is the card heading, so an in-spec
|
|
14
|
+
* title is a genuine duplicate render) and `axisLabel.interval` (pinned to `0`: ECharts' default
|
|
15
|
+
* `"auto"` SILENTLY DROPS labels, which is the exact bug this rule exists to prevent).
|
|
16
|
+
* 2. **Never turn a valid chart invalid.** Nothing here is derived from data that may be absent: a
|
|
17
|
+
* property is only injected where it is meaningful (a `grid` only for a cartesian chart), and an
|
|
18
|
+
* unknown category count yields the safe layout rather than a guessed one.
|
|
19
|
+
*
|
|
20
|
+
* Pure: the input spec is never mutated. Modified branches are copy-on-write; untouched branches are
|
|
21
|
+
* shared by reference. `normalize(normalize(x))` deep-equals `normalize(x)`.
|
|
22
|
+
*/
|
|
23
|
+
/** Max length of a derived `saveAsImage` filename. */
|
|
24
|
+
const MAX_FILENAME_LEN = 64;
|
|
25
|
+
/** Fallback download filename when no heading is available to derive one from. */
|
|
26
|
+
const DEFAULT_FILENAME = "chart";
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
/** The axis entries of an `xAxis`/`yAxis` option, which ECharts accepts as one object or an array. */
|
|
31
|
+
function axisEntries(axis) {
|
|
32
|
+
if (Array.isArray(axis))
|
|
33
|
+
return axis.filter(isRecord);
|
|
34
|
+
return isRecord(axis) ? [axis] : [];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Kebab-case download filename: `"HMGCR — VST Expression"` → `"hmgcr-vst-expression"`.
|
|
38
|
+
* Diacritics are folded, every other non-alphanumeric run collapses to a single dash.
|
|
39
|
+
*/
|
|
40
|
+
export function kebabFilename(source) {
|
|
41
|
+
const slug = (source ?? "")
|
|
42
|
+
.normalize("NFKD")
|
|
43
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
46
|
+
.slice(0, MAX_FILENAME_LEN)
|
|
47
|
+
.replace(/^-+|-+$/g, "");
|
|
48
|
+
return slug.length > 0 ? slug : DEFAULT_FILENAME;
|
|
49
|
+
}
|
|
50
|
+
/** Declared series count. `0` = none declared, which is also "nothing a legend could label". */
|
|
51
|
+
function seriesCount(spec) {
|
|
52
|
+
const series = spec.series;
|
|
53
|
+
if (Array.isArray(series))
|
|
54
|
+
return series.length;
|
|
55
|
+
return isRecord(series) ? 1 : 0;
|
|
56
|
+
}
|
|
57
|
+
/** True when any series maps its categories to dataset COLUMNS rather than rows. */
|
|
58
|
+
function laysOutByRow(spec) {
|
|
59
|
+
const series = Array.isArray(spec.series) ? spec.series : [spec.series];
|
|
60
|
+
return series.some((s) => isRecord(s) && s.seriesLayoutBy === "row");
|
|
61
|
+
}
|
|
62
|
+
/** Header rows to discount from a `dataset.source` array, per ECharts' `sourceHeader` rules. */
|
|
63
|
+
function headerRowCount(dataset, source) {
|
|
64
|
+
const declared = dataset.sourceHeader;
|
|
65
|
+
if (declared === true)
|
|
66
|
+
return 1;
|
|
67
|
+
if (declared === false)
|
|
68
|
+
return 0;
|
|
69
|
+
if (typeof declared === "number" && Number.isFinite(declared)) {
|
|
70
|
+
return Math.min(Math.max(Math.trunc(declared), 0), source.length);
|
|
71
|
+
}
|
|
72
|
+
// `"auto"` (the default): a leading row of all-strings in front of further rows is dimension names.
|
|
73
|
+
const first = source[0];
|
|
74
|
+
return source.length > 1 && Array.isArray(first) && first.length > 0 && first.every((cell) => typeof cell === "string") ? 1 : 0;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Category count on the x axis, or `null` when the spec cannot tell us.
|
|
78
|
+
*
|
|
79
|
+
* `null` is the honest answer for a CSV-backed chart (`show_user`'s `dataPath`): the rows are loaded
|
|
80
|
+
* by the host at render time and deliberately never pulled through the model's context window, so
|
|
81
|
+
* they are NOT in the spec. Guessing a rotation for an axis of unknown length is worse than not
|
|
82
|
+
* rotating — an unrotated axis is also exactly what the other `null` cases (a value axis: scatter,
|
|
83
|
+
* time series) want. The count is used ONLY to pick a label rotation; it never touches the data, so
|
|
84
|
+
* an off-by-one at a bucket boundary costs at most a rotation, never a rendering.
|
|
85
|
+
*/
|
|
86
|
+
export function categoryCount(spec) {
|
|
87
|
+
for (const axis of axisEntries(spec.xAxis)) {
|
|
88
|
+
// An explicit category list is unambiguous — prefer it over any dataset inference.
|
|
89
|
+
if (Array.isArray(axis.data))
|
|
90
|
+
return axis.data.length;
|
|
91
|
+
}
|
|
92
|
+
// An array of datasets, an object-of-columns `source`, or a row-major layout: do not guess.
|
|
93
|
+
const dataset = spec.dataset;
|
|
94
|
+
if (!isRecord(dataset) || laysOutByRow(spec))
|
|
95
|
+
return null;
|
|
96
|
+
const source = dataset.source;
|
|
97
|
+
if (!Array.isArray(source) || source.length === 0)
|
|
98
|
+
return null;
|
|
99
|
+
return Math.max(0, source.length - headerRowCount(dataset, source));
|
|
100
|
+
}
|
|
101
|
+
/** The label rotation for `count` categories, or `null` for "leave the labels horizontal". */
|
|
102
|
+
function rotationFor(count) {
|
|
103
|
+
if (count === null || count <= 10)
|
|
104
|
+
return null;
|
|
105
|
+
// >20 categories: ECharts' own escape hatch is a `dataZoom` slider, but that rewrites the chart's
|
|
106
|
+
// interaction model and its vertical budget — a 90° turn is the mutation that cannot surprise.
|
|
107
|
+
return count <= 20 ? 45 : 90;
|
|
108
|
+
}
|
|
109
|
+
/** True when the legend (authored or defaulted) sits below the chart and consumes bottom margin. */
|
|
110
|
+
function isBottomLegend(legend) {
|
|
111
|
+
const entries = Array.isArray(legend) ? legend.filter(isRecord) : isRecord(legend) ? [legend] : [];
|
|
112
|
+
return entries.some((entry) => entry.show !== false && entry.bottom !== undefined);
|
|
113
|
+
}
|
|
114
|
+
/** True when a component option carries any explicit placement — adding another would fight it. */
|
|
115
|
+
function hasPlacement(option) {
|
|
116
|
+
return option.left !== undefined || option.right !== undefined || option.top !== undefined || option.bottom !== undefined;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Apply the ECharts layout invariant to an author-composed spec.
|
|
120
|
+
*
|
|
121
|
+
* - `title` is deleted (always) — the `show_user` `title` param is the visible card heading, and
|
|
122
|
+
* `title.subtext` is the documented misuse for stats that belong in the tooltip.
|
|
123
|
+
* - `legend` defaults to `{ bottom: 0 }` for a multi-series chart and `{ show: false }` for a
|
|
124
|
+
* single-series one (nothing to disambiguate); an authored legend is left alone.
|
|
125
|
+
* - `grid` (cartesian charts only) gets `top` 8% — 12% when a `graphic` annotation shares the canvas
|
|
126
|
+
* — `bottom` 20% / 25% (rotated labels) / 30% (rotated labels above a bottom legend), `left` 10%,
|
|
127
|
+
* `right` 5%. Only unset keys are filled.
|
|
128
|
+
* - `axisLabel.interval` is pinned to `0` on every axis; the x axis is rotated 45° (11–20 categories)
|
|
129
|
+
* or 90° (>20) unless the author chose a rotation.
|
|
130
|
+
* - `toolbox.feature.saveAsImage` is injected with `type: "png"` and a kebab-case filename derived
|
|
131
|
+
* from the `show_user` title (falling back to the stripped in-spec title, then `"chart"`).
|
|
132
|
+
*/
|
|
133
|
+
export function normalizeEchartSpec(spec, opts = {}) {
|
|
134
|
+
const { title: specTitle, ...out } = spec;
|
|
135
|
+
// ── Legend ──────────────────────────────────────────────────────────
|
|
136
|
+
const legendAuthored = out.legend !== undefined && out.legend !== null;
|
|
137
|
+
if (!legendAuthored) {
|
|
138
|
+
const count = seriesCount(out);
|
|
139
|
+
if (count >= 2)
|
|
140
|
+
out.legend = { bottom: 0 };
|
|
141
|
+
// A single series has nothing to disambiguate: hide it explicitly rather than omit it, so a
|
|
142
|
+
// themed legend component cannot reintroduce an orphan swatch. An undeclared series count
|
|
143
|
+
// (0) gets no legend option at all — there is nothing to show or hide.
|
|
144
|
+
else if (count === 1)
|
|
145
|
+
out.legend = { show: false };
|
|
146
|
+
}
|
|
147
|
+
const bottomLegend = isBottomLegend(out.legend);
|
|
148
|
+
// ── Axis labels ─────────────────────────────────────────────────────
|
|
149
|
+
const rotate = rotationFor(categoryCount(out));
|
|
150
|
+
let rotated = false;
|
|
151
|
+
// Rotation is an x-axis rule: turning a horizontal bar chart's y-axis category labels on their
|
|
152
|
+
// side would be actively wrong, so only `interval` crosses to the y axis (where it is inert on a
|
|
153
|
+
// value axis and prevents silent label-skipping on a category one).
|
|
154
|
+
const normalizeAxis = (axis, isX) => {
|
|
155
|
+
const axisLabel = isRecord(axis.axisLabel) ? { ...axis.axisLabel } : {};
|
|
156
|
+
axisLabel.interval = 0;
|
|
157
|
+
if (isX && rotate !== null && axisLabel.rotate === undefined)
|
|
158
|
+
axisLabel.rotate = rotate;
|
|
159
|
+
if (isX && typeof axisLabel.rotate === "number" && axisLabel.rotate !== 0)
|
|
160
|
+
rotated = true;
|
|
161
|
+
return { ...axis, axisLabel };
|
|
162
|
+
};
|
|
163
|
+
for (const key of ["xAxis", "yAxis"]) {
|
|
164
|
+
const axis = out[key];
|
|
165
|
+
// A malformed entry is passed through rather than dropped — normalization rewrites layout,
|
|
166
|
+
// never the shape of the spec it was handed.
|
|
167
|
+
if (Array.isArray(axis))
|
|
168
|
+
out[key] = axis.map((entry) => (isRecord(entry) ? normalizeAxis(entry, key === "xAxis") : entry));
|
|
169
|
+
else if (isRecord(axis))
|
|
170
|
+
out[key] = normalizeAxis(axis, key === "xAxis");
|
|
171
|
+
}
|
|
172
|
+
// ── Grid ────────────────────────────────────────────────────────────
|
|
173
|
+
const cartesian = out.xAxis !== undefined || out.yAxis !== undefined;
|
|
174
|
+
const grid = out.grid;
|
|
175
|
+
// An array of grids, or a grid the author sized explicitly (`width`/`height`): a margin added on
|
|
176
|
+
// top of an explicit box could over-constrain it. Non-cartesian charts (pie, sunburst, graph)
|
|
177
|
+
// have no grid to lay out.
|
|
178
|
+
const gridNormalizable = cartesian && !Array.isArray(grid) && (grid === undefined || (isRecord(grid) && grid.width === undefined && grid.height === undefined));
|
|
179
|
+
if (gridNormalizable) {
|
|
180
|
+
const authored = isRecord(grid) ? grid : {};
|
|
181
|
+
out.grid = {
|
|
182
|
+
...authored,
|
|
183
|
+
...(authored.top === undefined ? { top: out.graphic !== undefined ? "12%" : "8%" } : {}),
|
|
184
|
+
...(authored.bottom === undefined ? { bottom: rotated ? (bottomLegend ? "30%" : "25%") : "20%" } : {}),
|
|
185
|
+
...(authored.left === undefined ? { left: "10%" } : {}),
|
|
186
|
+
...(authored.right === undefined ? { right: "5%" } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
// ── Toolbox ─────────────────────────────────────────────────────────
|
|
190
|
+
if (!Array.isArray(out.toolbox)) {
|
|
191
|
+
const authored = isRecord(out.toolbox) ? out.toolbox : {};
|
|
192
|
+
const feature = isRecord(authored.feature) ? authored.feature : {};
|
|
193
|
+
const saveAsImage = isRecord(feature.saveAsImage) ? feature.saveAsImage : {};
|
|
194
|
+
// The in-spec title is stripped, but it is still the author's own description of the chart —
|
|
195
|
+
// a better filename seed than the generic fallback when no card title was given.
|
|
196
|
+
const specTitleText = isRecord(specTitle) && typeof specTitle.text === "string" ? specTitle.text : undefined;
|
|
197
|
+
out.toolbox = {
|
|
198
|
+
...authored,
|
|
199
|
+
...(hasPlacement(authored) ? {} : { right: 0, top: 0 }),
|
|
200
|
+
feature: {
|
|
201
|
+
...feature,
|
|
202
|
+
saveAsImage: {
|
|
203
|
+
...saveAsImage,
|
|
204
|
+
...(saveAsImage.type === undefined ? { type: "png" } : {}),
|
|
205
|
+
...(saveAsImage.name === undefined ? { name: kebabFilename(opts.title ?? specTitleText) } : {}),
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* downstream reconciliation concern of the emit pipeline (change 3), not a
|
|
10
10
|
* per-request `Set` smuggled through an ambient context bag.
|
|
11
11
|
*
|
|
12
|
+
* An `echart` spec's layout is normalized (`normalize-echart-spec.ts`) inside
|
|
13
|
+
* `buildPresentationCardData`, the one construction site both this tool and the
|
|
14
|
+
* reconstruct-on-read path share — the model authors the data, the renderer owns
|
|
15
|
+
* the layout.
|
|
16
|
+
*
|
|
12
17
|
* For stored plans, use `show_plan`. For existing files, use `show_file`.
|
|
13
18
|
*/
|
|
14
19
|
type ShowUserOutput = {
|
|
@@ -18,7 +23,7 @@ type ShowUserOutput = {
|
|
|
18
23
|
id: string;
|
|
19
24
|
};
|
|
20
25
|
export declare const showUserTool: import("../define-tool.js").Tool<{
|
|
21
|
-
kind: "code" | "
|
|
26
|
+
kind: "code" | "markdown" | "echart" | "svg" | "table";
|
|
22
27
|
title?: string | undefined;
|
|
23
28
|
spec?: Record<string, unknown> | undefined;
|
|
24
29
|
dataPath?: string | undefined;
|