gasp-standard-mcp 1.3.1 → 1.5.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/README.md +12 -4
- package/build/index.js +294 -1
- package/build/loader.js +70 -1
- package/build/standard-data.json +1 -1
- package/build/standard-version.json +1 -1
- package/build/types.d.ts +64 -1
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -33,19 +33,26 @@ Restart your MCP client after adding the config.
|
|
|
33
33
|
|
|
34
34
|
| Tool | Description |
|
|
35
35
|
|------|-------------|
|
|
36
|
-
| `lookup_metric` | Full metric details: definition, formula, benchmarks, common mistakes, sources |
|
|
37
|
-
| `list_metrics` | List all metrics, optionally filtered by department |
|
|
36
|
+
| `lookup_metric` | Full metric details: definition, formula, benchmarks, entity, common mistakes, sources |
|
|
37
|
+
| `list_metrics` | List all metrics, optionally filtered by department and/or signal type |
|
|
38
38
|
| `get_formula` | Get formula(s) for a metric |
|
|
39
|
-
| `get_relationships` | Upstream
|
|
39
|
+
| `get_relationships` | Upstream/downstream relationships for metrics, entities, or concepts — includes business model edges (OWNS, GRANTS, CONVERTS_TO, etc.) |
|
|
40
40
|
| `get_data_requirements` | Data requirements for implementing a metric: fields, types, grain, source, and example SQL |
|
|
41
41
|
| `search` | Full-text search across metrics, definitions, and data requirements |
|
|
42
|
+
| `generate_query` | Generate warehouse-specific SQL from a metric name + adapter mapping |
|
|
43
|
+
| `validate_adapter` | Validate a GASP adapter mapping file — reports which metrics are fully/partially/not covered |
|
|
42
44
|
|
|
43
45
|
## Data
|
|
44
46
|
|
|
45
47
|
- 300 metrics across 13 departments
|
|
46
48
|
- 80 glossary definitions
|
|
47
|
-
-
|
|
49
|
+
- 76 relationship nodes (60 metrics + 11 entities + 5 concepts), 157 edges
|
|
50
|
+
- 15 entity-to-entity business model edges (OWNS, GRANTS, CONVERTS_TO, GENERATES, etc.)
|
|
48
51
|
- 78 data requirements with field types, grain, and example SQL
|
|
52
|
+
- 11 business entities, 5 business concepts, 22 source categories, 198 canonical fields
|
|
53
|
+
- 60 structured formulas with decomposed inputs (metric refs + field refs)
|
|
54
|
+
- 60 temporal mappings (grain, frequency, period for every metric node)
|
|
55
|
+
- CEL `change_nature` field for revenue quality analysis (Adjusted NRR/GRR)
|
|
49
56
|
- Sources: the canonical [GASP Standard](https://gaspwiki.com) files
|
|
50
57
|
|
|
51
58
|
## Examples
|
|
@@ -55,6 +62,7 @@ Once installed, ask your AI assistant:
|
|
|
55
62
|
- "What is NRR?"
|
|
56
63
|
- "How do I calculate CAC Payback?"
|
|
57
64
|
- "What drives ARR?"
|
|
65
|
+
- "How does Customer relate to Subscription?"
|
|
58
66
|
- "List all marketing metrics"
|
|
59
67
|
- "What data do I need to implement ARR tracking?"
|
|
60
68
|
- "Search for churn"
|
package/build/index.js
CHANGED
|
@@ -94,6 +94,25 @@ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbrevia
|
|
|
94
94
|
if (metric.aliases.length > 0) {
|
|
95
95
|
parts.push(`\n**Aliases:** ${metric.aliases.join(", ")}`);
|
|
96
96
|
}
|
|
97
|
+
// Entity mapping from ontology
|
|
98
|
+
if (data.ontology?.entityMappings) {
|
|
99
|
+
// Build reverse map: graph node ID → entity
|
|
100
|
+
for (const [entityId, metricIds] of Object.entries(data.ontology.entityMappings)) {
|
|
101
|
+
// Check if any alias/abbreviation of this metric matches a graph node ID
|
|
102
|
+
const lowerAliases = metric.aliases.map(a => a.toLowerCase().replace(/\s+/g, '_'));
|
|
103
|
+
const lowerName = metric.name.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
|
104
|
+
const matched = metricIds.some(id => lowerAliases.includes(id) ||
|
|
105
|
+
lowerName.startsWith(id.replace(/_/g, '_')) ||
|
|
106
|
+
metric.slug.startsWith(id.replace(/_/g, '-')));
|
|
107
|
+
if (matched) {
|
|
108
|
+
const entity = data.ontology.entities.find(e => e.id === entityId);
|
|
109
|
+
if (entity) {
|
|
110
|
+
parts.push(`\n**Measures:** ${entity.label} (${entity.aliases.join(", ")})`);
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
97
116
|
// Dual-lens content
|
|
98
117
|
if (metric.dualLens) {
|
|
99
118
|
const dl = metric.dualLens;
|
|
@@ -129,6 +148,16 @@ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbrevia
|
|
|
129
148
|
if (metric.dualLensNote) {
|
|
130
149
|
parts.push(`\n## Dual-Lens Note\n\n${metric.dualLensNote}`);
|
|
131
150
|
}
|
|
151
|
+
// Adjusted variant from structured formulas (e.g. NRR, GRR)
|
|
152
|
+
const sfMap = data.ontology?.structuredFormulas;
|
|
153
|
+
if (sfMap) {
|
|
154
|
+
const nodeId = metric.slug.replace(/-/g, '_');
|
|
155
|
+
const sf = sfMap[nodeId]
|
|
156
|
+
|| Object.entries(sfMap).find(([k]) => k !== 'description' && nodeId.startsWith(k + '_'))?.[1];
|
|
157
|
+
if (sf && typeof sf === 'object' && 'adjustedVariant' in sf) {
|
|
158
|
+
parts.push(`\n## Adjusted Variant\n\n${sf.adjustedVariant}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
132
161
|
// Swap EOM → GASP branding
|
|
133
162
|
const text = parts.join("\n").replace(/\bEOM Standard\b/g, "GASP Standard");
|
|
134
163
|
return { content: [{ type: "text", text }] };
|
|
@@ -221,11 +250,40 @@ server.tool("get_formula", "Get the formula(s) for a GASP Standard metric. Retur
|
|
|
221
250
|
}
|
|
222
251
|
parts.push("```\n" + metric.formulas[i] + "\n```\n");
|
|
223
252
|
}
|
|
253
|
+
// Append structured formula references if available
|
|
254
|
+
const formulas = data.ontology?.structuredFormulas;
|
|
255
|
+
const nodeId = metric.slug.replace(/-/g, '_');
|
|
256
|
+
// Match: exact node ID, or slug starts with a formula key (e.g. "arr_annual_recurring_revenue" starts with "arr")
|
|
257
|
+
let structured = formulas?.[nodeId];
|
|
258
|
+
if (!structured && formulas) {
|
|
259
|
+
for (const [key, val] of Object.entries(formulas)) {
|
|
260
|
+
if (key !== 'description' && nodeId.startsWith(key + '_')) {
|
|
261
|
+
structured = val;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (structured) {
|
|
267
|
+
if (structured.inputs && structured.inputs.length > 0) {
|
|
268
|
+
parts.push("## Inputs (metric dependencies)");
|
|
269
|
+
for (const input of structured.inputs) {
|
|
270
|
+
const inputNode = data.nodes.find(n => n.id === input.ref);
|
|
271
|
+
const label = inputNode?.label || input.ref;
|
|
272
|
+
parts.push(`- **${label}** (${input.role})`);
|
|
273
|
+
}
|
|
274
|
+
parts.push("");
|
|
275
|
+
}
|
|
276
|
+
if (structured.fields && structured.fields.length > 0) {
|
|
277
|
+
parts.push("## Required fields");
|
|
278
|
+
parts.push(structured.fields.map(f => `\`${f}\``).join(", "));
|
|
279
|
+
parts.push("");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
224
282
|
const text = parts.join("\n").replace(/\bEOM Standard\b/g, "GASP Standard");
|
|
225
283
|
return { content: [{ type: "text", text }] };
|
|
226
284
|
});
|
|
227
285
|
// --- Tool 4: get_relationships ---
|
|
228
|
-
server.tool("get_relationships", "Get the upstream (what drives it) and downstream (what it drives) relationships for a metric from the GASP relationship graph.", { metric: z.string().describe("Metric name or ID (e.g. 'arr', 'NRR', '
|
|
286
|
+
server.tool("get_relationships", "Get the upstream (what drives it) and downstream (what it drives) relationships for a metric, entity, or concept from the GASP relationship graph. Entity nodes show business model connections (OWNS, GRANTS, CONVERTS_TO, etc.).", { metric: z.string().describe("Metric name or ID (e.g. 'arr', 'NRR'), entity (e.g. 'customer', 'subscription'), or concept (e.g. 'revenue', 'bookings')") }, async ({ metric: name }) => {
|
|
229
287
|
const { node, upstream, downstream } = getRelationships(data, name);
|
|
230
288
|
if (!node) {
|
|
231
289
|
const available = data.nodes.map((n) => `${n.label} (${n.id})`).join(", ");
|
|
@@ -355,6 +413,241 @@ server.tool("search", "Search across all GASP Standard metrics and definitions.
|
|
|
355
413
|
}
|
|
356
414
|
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
357
415
|
});
|
|
416
|
+
// --- Tool 7: generate_query ---
|
|
417
|
+
server.tool("generate_query", "Generate a SQL query for a GASP metric using your adapter mapping. Takes a metric name and adapter JSON, fills in parameterized SQL templates with your warehouse column names.", {
|
|
418
|
+
metric: z
|
|
419
|
+
.string()
|
|
420
|
+
.describe("Metric name to generate SQL for (e.g. 'NRR', 'CAC', 'Pipeline Value', 'ARR')"),
|
|
421
|
+
adapter: z
|
|
422
|
+
.string()
|
|
423
|
+
.describe("Your GASP adapter JSON string with field mappings"),
|
|
424
|
+
}, async ({ metric: metricName, adapter: adapterJson }) => {
|
|
425
|
+
if (!data.queryTemplates) {
|
|
426
|
+
return {
|
|
427
|
+
content: [{ type: "text", text: "Query templates not available. Update the GASP Standard data." }],
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
// Parse adapter
|
|
431
|
+
let adapter;
|
|
432
|
+
try {
|
|
433
|
+
adapter = JSON.parse(adapterJson);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return {
|
|
437
|
+
content: [{ type: "text", text: "Invalid adapter JSON." }],
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (!adapter.mappings || !Array.isArray(adapter.mappings)) {
|
|
441
|
+
return {
|
|
442
|
+
content: [{ type: "text", text: 'Adapter must have a "mappings" array.' }],
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
// Find matching template (fuzzy match on metric name)
|
|
446
|
+
const q = metricName.toLowerCase();
|
|
447
|
+
let templateKey;
|
|
448
|
+
let template;
|
|
449
|
+
// Exact match first
|
|
450
|
+
for (const [key, tmpl] of Object.entries(data.queryTemplates)) {
|
|
451
|
+
if (key.toLowerCase() === q) {
|
|
452
|
+
templateKey = key;
|
|
453
|
+
template = tmpl;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Partial match
|
|
458
|
+
if (!template) {
|
|
459
|
+
for (const [key, tmpl] of Object.entries(data.queryTemplates)) {
|
|
460
|
+
if (key.toLowerCase().includes(q) || q.includes(key.toLowerCase())) {
|
|
461
|
+
templateKey = key;
|
|
462
|
+
template = tmpl;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (!template || !templateKey) {
|
|
468
|
+
const available = Object.keys(data.queryTemplates).slice(0, 20).join(", ");
|
|
469
|
+
return {
|
|
470
|
+
content: [{
|
|
471
|
+
type: "text",
|
|
472
|
+
text: `No query template found for "${metricName}".\n\nAvailable templates: ${available}...`,
|
|
473
|
+
}],
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
// Build field → warehouseRef map from adapter
|
|
477
|
+
const fieldMap = new Map();
|
|
478
|
+
for (const m of adapter.mappings) {
|
|
479
|
+
if (m.gaspField && m.warehouseRef && m.warehouseRef.trim() !== "") {
|
|
480
|
+
fieldMap.set(m.gaspField, { ref: m.warehouseRef, transform: m.transform });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Check which template fields are mapped
|
|
484
|
+
const mapped = [];
|
|
485
|
+
const missing = [];
|
|
486
|
+
for (const field of template.fields) {
|
|
487
|
+
if (fieldMap.has(field))
|
|
488
|
+
mapped.push(field);
|
|
489
|
+
else
|
|
490
|
+
missing.push(field);
|
|
491
|
+
}
|
|
492
|
+
// Fill in the template
|
|
493
|
+
let sql = template.parameterizedQuery;
|
|
494
|
+
// Replace {{field}} placeholders with warehouse refs
|
|
495
|
+
for (const [field, mapping] of fieldMap) {
|
|
496
|
+
const placeholder = `{{${field}}}`;
|
|
497
|
+
if (sql.includes(placeholder)) {
|
|
498
|
+
// Extract just the column name (last part of schema.table.column)
|
|
499
|
+
const parts = mapping.ref.split(".");
|
|
500
|
+
const column = parts[parts.length - 1];
|
|
501
|
+
const replacement = mapping.transform
|
|
502
|
+
? mapping.transform.replace(/\{\{value\}\}/g, column)
|
|
503
|
+
: column;
|
|
504
|
+
sql = sql.split(placeholder).join(replacement);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Replace {{_table}} with the most common table from mapped fields
|
|
508
|
+
if (sql.includes("{{_table}}")) {
|
|
509
|
+
const tableCounts = new Map();
|
|
510
|
+
for (const field of template.fields) {
|
|
511
|
+
const m = fieldMap.get(field);
|
|
512
|
+
if (m) {
|
|
513
|
+
const parts = m.ref.split(".");
|
|
514
|
+
// Table is everything except the last segment (column)
|
|
515
|
+
const table = parts.slice(0, -1).join(".");
|
|
516
|
+
if (table)
|
|
517
|
+
tableCounts.set(table, (tableCounts.get(table) || 0) + 1);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// Pick the most-referenced table
|
|
521
|
+
let bestTable = "your_table";
|
|
522
|
+
let bestCount = 0;
|
|
523
|
+
for (const [table, count] of tableCounts) {
|
|
524
|
+
if (count > bestCount) {
|
|
525
|
+
bestTable = table;
|
|
526
|
+
bestCount = count;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
sql = sql.split("{{_table}}").join(bestTable);
|
|
530
|
+
}
|
|
531
|
+
// Mark any remaining unfilled placeholders
|
|
532
|
+
const unfilled = sql.match(/\{\{[^}]+\}\}/g);
|
|
533
|
+
if (unfilled) {
|
|
534
|
+
sql = sql + "\n\n-- NOTE: Unmapped fields (fill in your adapter): " + [...new Set(unfilled)].join(", ");
|
|
535
|
+
}
|
|
536
|
+
// Build response
|
|
537
|
+
const parts = [
|
|
538
|
+
`# ${templateKey} — Generated SQL\n`,
|
|
539
|
+
`**Warehouse:** ${adapter.warehouse || "not specified"}`,
|
|
540
|
+
`**Source:** ${template.source}`,
|
|
541
|
+
`**Grain:** ${template.grain}`,
|
|
542
|
+
`**Fields mapped:** ${mapped.length} / ${template.fields.length}\n`,
|
|
543
|
+
];
|
|
544
|
+
if (missing.length > 0) {
|
|
545
|
+
parts.push(`**Missing fields:** \`${missing.join("`, `")}\`\n`);
|
|
546
|
+
}
|
|
547
|
+
parts.push("```sql\n" + sql + "\n```");
|
|
548
|
+
if (mapped.length > 0) {
|
|
549
|
+
parts.push("\n**Field mapping used:**");
|
|
550
|
+
for (const field of mapped) {
|
|
551
|
+
const m = fieldMap.get(field);
|
|
552
|
+
const line = `- \`${field}\` → \`${m.ref}\`${m.transform ? ` (transform: ${m.transform})` : ""}`;
|
|
553
|
+
parts.push(line);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
557
|
+
});
|
|
558
|
+
// --- Tool 8: validate_adapter ---
|
|
559
|
+
server.tool("validate_adapter", "Validate a GASP adapter mapping file. Takes a JSON string of your adapter and reports which GASP metrics are fully covered, partially covered, or missing based on your mapped fields.", {
|
|
560
|
+
adapter: z
|
|
561
|
+
.string()
|
|
562
|
+
.describe("The adapter JSON as a string. Must follow the GASP adapter schema with version, organization, warehouse, and mappings array."),
|
|
563
|
+
}, async ({ adapter: adapterJson }) => {
|
|
564
|
+
// Parse the adapter
|
|
565
|
+
let adapter;
|
|
566
|
+
try {
|
|
567
|
+
adapter = JSON.parse(adapterJson);
|
|
568
|
+
}
|
|
569
|
+
catch (e) {
|
|
570
|
+
return {
|
|
571
|
+
content: [{ type: "text", text: "Invalid JSON. The adapter must be a valid JSON object." }],
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
if (!adapter.mappings || !Array.isArray(adapter.mappings)) {
|
|
575
|
+
return {
|
|
576
|
+
content: [{ type: "text", text: 'Adapter must have a "mappings" array. Download the template from https://gaspwiki.com/downloads/gasp-adapter-template.json' }],
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
// Build set of mapped fields (only those with a non-empty warehouseRef)
|
|
580
|
+
const mappedFields = new Set();
|
|
581
|
+
for (const m of adapter.mappings) {
|
|
582
|
+
if (m.gaspField && m.warehouseRef && m.warehouseRef.trim() !== "") {
|
|
583
|
+
mappedFields.add(m.gaspField);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Check each data requirement
|
|
587
|
+
const drs = data.dataRequirements || [];
|
|
588
|
+
const fullyCovered = [];
|
|
589
|
+
const partiallyCovered = [];
|
|
590
|
+
const notCovered = [];
|
|
591
|
+
for (const dr of drs) {
|
|
592
|
+
const mapped = dr.fields.filter((f) => mappedFields.has(f));
|
|
593
|
+
const missing = dr.fields.filter((f) => !mappedFields.has(f));
|
|
594
|
+
if (missing.length === 0) {
|
|
595
|
+
fullyCovered.push(dr.metric);
|
|
596
|
+
}
|
|
597
|
+
else if (mapped.length > 0) {
|
|
598
|
+
partiallyCovered.push({ metric: dr.metric, mapped, missing });
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
notCovered.push(dr.metric);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// Find unmapped GASP fields
|
|
605
|
+
const allGaspFields = new Set();
|
|
606
|
+
for (const dr of drs) {
|
|
607
|
+
for (const f of dr.fields)
|
|
608
|
+
allGaspFields.add(f);
|
|
609
|
+
}
|
|
610
|
+
const unmappedFields = [...allGaspFields].filter((f) => !mappedFields.has(f)).sort();
|
|
611
|
+
// Build report
|
|
612
|
+
const org = adapter.organization || "Unknown";
|
|
613
|
+
const wh = adapter.warehouse || "not specified";
|
|
614
|
+
const parts = [
|
|
615
|
+
`# GASP Adapter Validation — ${org}\n`,
|
|
616
|
+
`**Warehouse:** ${wh}`,
|
|
617
|
+
`**Mapped fields:** ${mappedFields.size} / ${allGaspFields.size} (${Math.round((mappedFields.size / allGaspFields.size) * 100)}%)\n`,
|
|
618
|
+
];
|
|
619
|
+
if (fullyCovered.length > 0) {
|
|
620
|
+
parts.push(`## Fully Covered Metrics (${fullyCovered.length})`);
|
|
621
|
+
parts.push("These metrics have all required fields mapped and can be computed:\n");
|
|
622
|
+
for (const m of fullyCovered)
|
|
623
|
+
parts.push(`- ${m}`);
|
|
624
|
+
parts.push("");
|
|
625
|
+
}
|
|
626
|
+
if (partiallyCovered.length > 0) {
|
|
627
|
+
parts.push(`## Partially Covered Metrics (${partiallyCovered.length})`);
|
|
628
|
+
parts.push("These metrics are missing some fields:\n");
|
|
629
|
+
for (const p of partiallyCovered) {
|
|
630
|
+
parts.push(`- **${p.metric}** — missing: \`${p.missing.join("`, `")}\``);
|
|
631
|
+
}
|
|
632
|
+
parts.push("");
|
|
633
|
+
}
|
|
634
|
+
if (notCovered.length > 0) {
|
|
635
|
+
parts.push(`## Not Covered (${notCovered.length})`);
|
|
636
|
+
parts.push("No fields mapped for these metrics:\n");
|
|
637
|
+
for (const m of notCovered)
|
|
638
|
+
parts.push(`- ${m}`);
|
|
639
|
+
parts.push("");
|
|
640
|
+
}
|
|
641
|
+
if (unmappedFields.length > 0 && unmappedFields.length <= 30) {
|
|
642
|
+
parts.push(`## Unmapped Fields (${unmappedFields.length})`);
|
|
643
|
+
parts.push(`\`${unmappedFields.join("`, `")}\``);
|
|
644
|
+
}
|
|
645
|
+
else if (unmappedFields.length > 30) {
|
|
646
|
+
parts.push(`## Unmapped Fields (${unmappedFields.length})`);
|
|
647
|
+
parts.push(`First 30: \`${unmappedFields.slice(0, 30).join("`, `")}\` ...`);
|
|
648
|
+
}
|
|
649
|
+
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
650
|
+
});
|
|
358
651
|
// Start the server
|
|
359
652
|
async function main() {
|
|
360
653
|
const transport = new StdioServerTransport();
|
package/build/loader.js
CHANGED
|
@@ -97,7 +97,76 @@ export function loadStandard() {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
-
|
|
100
|
+
// Load query templates
|
|
101
|
+
const queryTemplatePath = join(standardDir, "query-templates.json");
|
|
102
|
+
let queryTemplates;
|
|
103
|
+
try {
|
|
104
|
+
const qtRaw = JSON.parse(readFileSync(queryTemplatePath, "utf-8"));
|
|
105
|
+
queryTemplates = qtRaw.templates || {};
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// query-templates.json not yet created
|
|
109
|
+
}
|
|
110
|
+
// Load ontology
|
|
111
|
+
const ontologyPath = join(standardDir, "ontology.json");
|
|
112
|
+
let ontology;
|
|
113
|
+
try {
|
|
114
|
+
const ontRaw = JSON.parse(readFileSync(ontologyPath, "utf-8"));
|
|
115
|
+
ontology = {
|
|
116
|
+
entities: ontRaw.entities || [],
|
|
117
|
+
entityMappings: ontRaw.entityMappings || {},
|
|
118
|
+
concepts: ontRaw.concepts || [],
|
|
119
|
+
entityRelationships: ontRaw.entityRelationships || undefined,
|
|
120
|
+
structuredFormulas: ontRaw.structuredFormulas || undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// ontology.json not yet created
|
|
125
|
+
}
|
|
126
|
+
// Enrich relationship nodes with descriptions
|
|
127
|
+
const defBySlug = new Map();
|
|
128
|
+
for (const m of metrics) {
|
|
129
|
+
if (m.slug && m.definition)
|
|
130
|
+
defBySlug.set(m.slug, m.definition);
|
|
131
|
+
}
|
|
132
|
+
for (const d of definitions) {
|
|
133
|
+
if (d.slug && d.definition && !defBySlug.has(d.slug))
|
|
134
|
+
defBySlug.set(d.slug, d.definition);
|
|
135
|
+
}
|
|
136
|
+
for (const node of nodes) {
|
|
137
|
+
if (node.nodeType === 'entity' && ontology) {
|
|
138
|
+
const entityId = node.id.replace('entity_', '');
|
|
139
|
+
const entity = ontology.entities.find(e => e.id === entityId);
|
|
140
|
+
node.nodeType = 'entity';
|
|
141
|
+
node.description = entity?.definition || '';
|
|
142
|
+
node.category = 'entity';
|
|
143
|
+
}
|
|
144
|
+
else if (node.nodeType === 'concept' && ontology?.concepts) {
|
|
145
|
+
const conceptId = node.id.replace('concept_', '');
|
|
146
|
+
const concept = ontology.concepts.find(c => c.id === conceptId);
|
|
147
|
+
node.nodeType = 'concept';
|
|
148
|
+
node.description = concept?.definition || '';
|
|
149
|
+
node.category = 'concept';
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
// Try to find metric description by slug
|
|
153
|
+
node.department = node.department || '';
|
|
154
|
+
node.signalType = node.signalType || '';
|
|
155
|
+
node.category = node.category || node.department || '';
|
|
156
|
+
if (!node.description) {
|
|
157
|
+
// Build slug from the metric-links mapping pattern
|
|
158
|
+
const slug = node.id.replace(/_/g, '-');
|
|
159
|
+
// Search for a matching definition
|
|
160
|
+
for (const [key, def] of defBySlug) {
|
|
161
|
+
if (key.includes(slug) || slug.includes(key.split('-')[0])) {
|
|
162
|
+
node.description = def;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { metrics, definitions, nodes, edges, dataRequirements, ontology, queryTemplates };
|
|
101
170
|
}
|
|
102
171
|
/**
|
|
103
172
|
* Search metrics + definitions for a query string.
|