formlab-mcp 0.1.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 +136 -0
- package/data.js +163 -0
- package/index.js +122 -0
- package/package.json +37 -0
- package/tools/analytics.js +426 -0
- package/tools/formulations.js +214 -0
- package/tools/ingredients.js +103 -0
- package/tools/lab.js +234 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// ANALYTICS TOOLS — the headline value of the MCP for AI use:
|
|
3
|
+
// - list_test_results
|
|
4
|
+
// - get_test_result
|
|
5
|
+
// - get_doe_matrix (the same shape the DOE Matrix UI produces)
|
|
6
|
+
// - find_failures (Pareto-style: which params fail most)
|
|
7
|
+
// - get_coverage_matrix (which items × parameters have been tested)
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
11
|
+
|
|
12
|
+
function _trimTest(t, samplesById) {
|
|
13
|
+
if (!t) return null;
|
|
14
|
+
const s = t.sampleId ? samplesById.get(t.sampleId) : null;
|
|
15
|
+
return {
|
|
16
|
+
id: t.id,
|
|
17
|
+
uid: t.uid,
|
|
18
|
+
reportName: t.reportName || '',
|
|
19
|
+
sample: s ? { id: s.id, uid: s.uid } : null,
|
|
20
|
+
testDate: t.testDate || '',
|
|
21
|
+
performedBy: t.performedBy || '',
|
|
22
|
+
lab: t.lab || '',
|
|
23
|
+
measurementCount: (t.measurements || []).length,
|
|
24
|
+
parameters: [...new Set((t.measurements || []).map(m => m.parameter).filter(Boolean))],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function _trimMeasurement(m) {
|
|
29
|
+
return {
|
|
30
|
+
parameter: m.parameter || '',
|
|
31
|
+
value: m.value ?? null,
|
|
32
|
+
unit: m.unit || '',
|
|
33
|
+
type: m.type || 'scalar',
|
|
34
|
+
pointCount: Array.isArray(m.points) ? m.points.length : 0,
|
|
35
|
+
binCount: Array.isArray(m.bins) ? m.bins.length : 0,
|
|
36
|
+
acceptanceCriteria: m.acceptanceCriteria || null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const list_test_results = {
|
|
41
|
+
definition: {
|
|
42
|
+
name: 'list_test_results',
|
|
43
|
+
description: 'List test result reports, optionally filtered by sample, parameter, date range, or lab. Returns metadata + parameters tested. Use get_test_result for full measurement values.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
sample_id: { type: 'string', description: 'Filter to results for this sample (id or UID).' },
|
|
48
|
+
parameter: { type: 'string', description: 'Filter to results that include this parameter (case-insensitive substring).' },
|
|
49
|
+
since_date: { type: 'string', description: 'ISO date — only reports on/after this date.' },
|
|
50
|
+
until_date: { type: 'string', description: 'ISO date — only reports on/before this date.' },
|
|
51
|
+
lab: { type: 'string', description: 'Filter by lab name (case-insensitive substring).' },
|
|
52
|
+
limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
handler: async (args) => {
|
|
57
|
+
const { db, indexes } = getStore();
|
|
58
|
+
const limit = Math.min(1000, Math.max(1, args.limit || 100));
|
|
59
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
60
|
+
let sampleId = null;
|
|
61
|
+
if (args.sample_id) {
|
|
62
|
+
const s = resolveById('samples', args.sample_id);
|
|
63
|
+
if (!s) return { error: `No sample matched "${args.sample_id}".` };
|
|
64
|
+
sampleId = s.id;
|
|
65
|
+
}
|
|
66
|
+
const filtered = (db.testResults || []).filter(t => {
|
|
67
|
+
if (!t || t._trashed) return false;
|
|
68
|
+
if (sampleId && t.sampleId !== sampleId) return false;
|
|
69
|
+
if (args.since_date && (t.testDate || '') < args.since_date) return false;
|
|
70
|
+
if (args.until_date && (t.testDate || '') > args.until_date) return false;
|
|
71
|
+
if (args.lab && !norm(t.lab).includes(norm(args.lab))) return false;
|
|
72
|
+
if (args.parameter) {
|
|
73
|
+
const found = (t.measurements || []).some(m => norm(m.parameter).includes(norm(args.parameter)));
|
|
74
|
+
if (!found) return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
totalMatching: filtered.length,
|
|
80
|
+
returned: Math.min(filtered.length, limit),
|
|
81
|
+
testResults: filtered.slice(0, limit).map(t => _trimTest(t, indexes.samplesById)),
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const get_test_result = {
|
|
87
|
+
definition: {
|
|
88
|
+
name: 'get_test_result',
|
|
89
|
+
description: 'Get full details for a single test result report including every measurement\'s value, unit, type, and acceptance criteria. Accepts internal id or UID.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
id: { type: 'string', description: 'Internal id or UID of the test result.' },
|
|
94
|
+
},
|
|
95
|
+
required: ['id'],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
handler: async (args) => {
|
|
99
|
+
const { indexes } = getStore();
|
|
100
|
+
const t = resolveById('testResults', args.id);
|
|
101
|
+
if (!t) return { error: `No test result found for id "${args.id}".` };
|
|
102
|
+
return {
|
|
103
|
+
..._trimTest(t, indexes.samplesById),
|
|
104
|
+
notes: t.notes || '',
|
|
105
|
+
measurements: (t.measurements || []).map(_trimMeasurement),
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const get_doe_matrix = {
|
|
111
|
+
definition: {
|
|
112
|
+
name: 'get_doe_matrix',
|
|
113
|
+
description: 'Build a Design-of-Experiments style pivot matrix: rows = formulations (or samples / batches / tests), columns = composition wt-% per ingredient + aggregated test-parameter values. Same shape as the FormLab DOE Matrix view. Returns CSV when format=csv (default) for compact LLM consumption, or structured JSON when format=json.',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
grain: { type: 'string', enum: ['formulation', 'sample', 'batch', 'test'], description: 'Row grain. Default "formulation".' },
|
|
118
|
+
format: { type: 'string', enum: ['csv', 'json'], description: 'Output format. CSV is recommended for LLM consumption (compact). Default "csv".' },
|
|
119
|
+
formulation_ids: { type: 'array', items: { type: 'string' }, description: 'Restrict to specific formulation ids/UIDs.' },
|
|
120
|
+
parameter_filter: { type: 'string', description: 'Case-insensitive substring; restrict parameter columns.' },
|
|
121
|
+
limit_rows: { type: 'number', description: 'Max rows (default 100, max 500).' },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
handler: async (args) => {
|
|
126
|
+
const { db, indexes } = getStore();
|
|
127
|
+
const grain = args.grain || 'formulation';
|
|
128
|
+
const fmt = args.format || 'csv';
|
|
129
|
+
const limit = Math.min(500, Math.max(1, args.limit_rows || 100));
|
|
130
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
131
|
+
|
|
132
|
+
// 1) Resolve formula filter
|
|
133
|
+
let allowedFormulaIds = null;
|
|
134
|
+
if (Array.isArray(args.formulation_ids) && args.formulation_ids.length) {
|
|
135
|
+
const ids = args.formulation_ids.map(i => resolveById('formulations', i)?.id).filter(Boolean);
|
|
136
|
+
if (!ids.length) return { error: 'None of the provided formulation_ids matched a formula.' };
|
|
137
|
+
allowedFormulaIds = new Set(ids);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 2) Build rows + per-row composition map + per-row measurements
|
|
141
|
+
const rows = [];
|
|
142
|
+
const addRow = (label, formulationId, measurements, batchActualComp) => {
|
|
143
|
+
let composition = null;
|
|
144
|
+
// For the composition, prefer batch.actualComposition if present
|
|
145
|
+
// (matches the "Use measured" semantics in the FormLab UI when the
|
|
146
|
+
// grain is batch/sample/test); else flatten the proposal formula.
|
|
147
|
+
const f = formulationId ? indexes.formulationsById.get(formulationId) : null;
|
|
148
|
+
if (batchActualComp && Array.isArray(batchActualComp) && batchActualComp.length) {
|
|
149
|
+
composition = new Map();
|
|
150
|
+
batchActualComp.forEach(c => {
|
|
151
|
+
if (c && c.ingredientId) {
|
|
152
|
+
const amt = parseFloat(c.amount);
|
|
153
|
+
if (isFinite(amt)) composition.set(c.ingredientId, amt);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
} else if (f) {
|
|
157
|
+
const flat = flattenComposition(f);
|
|
158
|
+
composition = new Map(flat.rows.map(r => [r.ingredientId, +(r.weightFraction * 100).toFixed(4)]));
|
|
159
|
+
} else {
|
|
160
|
+
composition = new Map();
|
|
161
|
+
}
|
|
162
|
+
rows.push({ label, composition, measurements: measurements || [] });
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (grain === 'formulation') {
|
|
166
|
+
(db.formulations || []).forEach(f => {
|
|
167
|
+
if (!f || f._trashed) return;
|
|
168
|
+
if (allowedFormulaIds && !allowedFormulaIds.has(f.id)) return;
|
|
169
|
+
const samples = (db.samples || []).filter(s => s.formulationId === f.id);
|
|
170
|
+
const sampleIds = new Set(samples.map(s => s.id));
|
|
171
|
+
const measurements = (db.testResults || [])
|
|
172
|
+
.filter(t => sampleIds.has(t.sampleId))
|
|
173
|
+
.flatMap(t => t.measurements || []);
|
|
174
|
+
addRow(f.name || f.uid || f.id, f.id, measurements);
|
|
175
|
+
});
|
|
176
|
+
} else if (grain === 'batch') {
|
|
177
|
+
(db.batches || []).forEach(b => {
|
|
178
|
+
if (!b || b._trashed) return;
|
|
179
|
+
if (allowedFormulaIds && !allowedFormulaIds.has(b.formulationId)) return;
|
|
180
|
+
const samples = (db.samples || []).filter(s => s.batchId === b.id);
|
|
181
|
+
const sampleIds = new Set(samples.map(s => s.id));
|
|
182
|
+
const measurements = (db.testResults || [])
|
|
183
|
+
.filter(t => sampleIds.has(t.sampleId))
|
|
184
|
+
.flatMap(t => t.measurements || []);
|
|
185
|
+
addRow(b.uid || b.id, b.formulationId, measurements, b.actualComposition);
|
|
186
|
+
});
|
|
187
|
+
} else if (grain === 'sample') {
|
|
188
|
+
(db.samples || []).forEach(s => {
|
|
189
|
+
if (!s || s._trashed) return;
|
|
190
|
+
if (allowedFormulaIds && !allowedFormulaIds.has(s.formulationId)) return;
|
|
191
|
+
const measurements = (db.testResults || [])
|
|
192
|
+
.filter(t => t.sampleId === s.id)
|
|
193
|
+
.flatMap(t => t.measurements || []);
|
|
194
|
+
const batch = s.batchId ? indexes.batchesById.get(s.batchId) : null;
|
|
195
|
+
addRow(s.uid || s.id, s.formulationId, measurements, batch?.actualComposition);
|
|
196
|
+
});
|
|
197
|
+
} else if (grain === 'test') {
|
|
198
|
+
(db.testResults || []).forEach(t => {
|
|
199
|
+
if (!t || t._trashed) return;
|
|
200
|
+
const samp = indexes.samplesById.get(t.sampleId);
|
|
201
|
+
if (allowedFormulaIds && !allowedFormulaIds.has(samp?.formulationId)) return;
|
|
202
|
+
const batch = samp?.batchId ? indexes.batchesById.get(samp.batchId) : null;
|
|
203
|
+
addRow(t.uid || t.id, samp?.formulationId, t.measurements || [], batch?.actualComposition);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 3) Build column sets
|
|
208
|
+
const ingredientIds = new Set();
|
|
209
|
+
const parameters = new Set();
|
|
210
|
+
rows.forEach(r => {
|
|
211
|
+
r.composition.forEach((_, k) => ingredientIds.add(k));
|
|
212
|
+
r.measurements.forEach(m => {
|
|
213
|
+
if (!m.parameter) return;
|
|
214
|
+
if (args.parameter_filter && !norm(m.parameter).includes(norm(args.parameter_filter))) return;
|
|
215
|
+
parameters.add(m.parameter);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
const ingCols = [...ingredientIds].map(id => {
|
|
219
|
+
const ing = indexes.ingredientsById.get(id);
|
|
220
|
+
return { id, label: (ing?.name || id) + ' (%)' };
|
|
221
|
+
});
|
|
222
|
+
const paramCols = [...parameters].map(p => ({ id: p, label: p }));
|
|
223
|
+
|
|
224
|
+
// 4) Aggregate measurements per param per row (mean of numeric scalar values)
|
|
225
|
+
const cellParamValue = (r, param) => {
|
|
226
|
+
const ms = r.measurements.filter(m => m.parameter === param);
|
|
227
|
+
if (!ms.length) return null;
|
|
228
|
+
const nums = ms.map(m => parseFloat(m.value)).filter(v => isFinite(v));
|
|
229
|
+
if (!nums.length) return ms[0].value ?? null;
|
|
230
|
+
const mean = nums.reduce((s, v) => s + v, 0) / nums.length;
|
|
231
|
+
return +mean.toFixed(4);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const limitedRows = rows.slice(0, limit);
|
|
235
|
+
|
|
236
|
+
if (fmt === 'json') {
|
|
237
|
+
return {
|
|
238
|
+
grain,
|
|
239
|
+
rowCount: rows.length,
|
|
240
|
+
rowsReturned: limitedRows.length,
|
|
241
|
+
ingredientColumnCount: ingCols.length,
|
|
242
|
+
parameterColumnCount: paramCols.length,
|
|
243
|
+
columns: { label: 'Row', ingredients: ingCols.map(c => c.label), parameters: paramCols.map(c => c.label) },
|
|
244
|
+
rows: limitedRows.map(r => ({
|
|
245
|
+
label: r.label,
|
|
246
|
+
ingredientPct: Object.fromEntries(ingCols.map(c => [c.label, r.composition.get(c.id) ?? null])),
|
|
247
|
+
parameterMean: Object.fromEntries(paramCols.map(c => [c.label, cellParamValue(r, c.id)])),
|
|
248
|
+
})),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// CSV format (default — compact, easy to paste into a spreadsheet)
|
|
253
|
+
const escCSV = (v) => {
|
|
254
|
+
if (v == null) return '';
|
|
255
|
+
const s = String(v);
|
|
256
|
+
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
257
|
+
};
|
|
258
|
+
const headerCells = ['Row', ...ingCols.map(c => c.label), ...paramCols.map(c => c.label)];
|
|
259
|
+
const lines = [headerCells.map(escCSV).join(',')];
|
|
260
|
+
limitedRows.forEach(r => {
|
|
261
|
+
const cells = [
|
|
262
|
+
r.label,
|
|
263
|
+
...ingCols.map(c => r.composition.get(c.id) ?? ''),
|
|
264
|
+
...paramCols.map(c => cellParamValue(r, c.id) ?? ''),
|
|
265
|
+
];
|
|
266
|
+
lines.push(cells.map(escCSV).join(','));
|
|
267
|
+
});
|
|
268
|
+
const summary = `# DOE Matrix · grain=${grain} · ${limitedRows.length}/${rows.length} rows · ${ingCols.length} ingredient cols · ${paramCols.length} parameter cols`;
|
|
269
|
+
return [summary, ...lines].join('\n');
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const find_failures = {
|
|
274
|
+
definition: {
|
|
275
|
+
name: 'find_failures',
|
|
276
|
+
description: 'Pareto-style failure analysis: which test parameters fail their acceptance criteria most often, and on which samples / formulations. Returns ranked list of (parameter, failureCount, failureRate, exampleFailures).',
|
|
277
|
+
inputSchema: {
|
|
278
|
+
type: 'object',
|
|
279
|
+
properties: {
|
|
280
|
+
parameter: { type: 'string', description: 'Restrict to a single parameter (case-insensitive substring).' },
|
|
281
|
+
since_date: { type: 'string', description: 'ISO date — only failures since this date.' },
|
|
282
|
+
limit_params: { type: 'number', description: 'Max parameters in the Pareto. Default 10.' },
|
|
283
|
+
limit_examples_per_param: { type: 'number', description: 'Max example failures per parameter row. Default 5.' },
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
handler: async (args) => {
|
|
288
|
+
const { db, indexes } = getStore();
|
|
289
|
+
const limitParams = Math.min(50, Math.max(1, args.limit_params || 10));
|
|
290
|
+
const limitExamples = Math.min(20, Math.max(1, args.limit_examples_per_param || 5));
|
|
291
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
292
|
+
|
|
293
|
+
// Per-parameter aggregator
|
|
294
|
+
const byParam = new Map();
|
|
295
|
+
(db.testResults || []).forEach(t => {
|
|
296
|
+
if (!t || t._trashed) return;
|
|
297
|
+
if (args.since_date && (t.testDate || '') < args.since_date) return;
|
|
298
|
+
(t.measurements || []).forEach(m => {
|
|
299
|
+
if (!m || !m.parameter) return;
|
|
300
|
+
if (args.parameter && !norm(m.parameter).includes(norm(args.parameter))) return;
|
|
301
|
+
if (!byParam.has(m.parameter)) {
|
|
302
|
+
byParam.set(m.parameter, { total: 0, failures: 0, examples: [] });
|
|
303
|
+
}
|
|
304
|
+
const agg = byParam.get(m.parameter);
|
|
305
|
+
agg.total += 1;
|
|
306
|
+
const pf = _evalPassFail(m);
|
|
307
|
+
if (pf === 'fail') {
|
|
308
|
+
agg.failures += 1;
|
|
309
|
+
if (agg.examples.length < limitExamples) {
|
|
310
|
+
const samp = indexes.samplesById.get(t.sampleId);
|
|
311
|
+
const f = samp?.formulationId ? indexes.formulationsById.get(samp.formulationId) : null;
|
|
312
|
+
agg.examples.push({
|
|
313
|
+
testId: t.uid || t.id,
|
|
314
|
+
testDate: t.testDate || '',
|
|
315
|
+
sample: samp ? (samp.uid || samp.id) : '',
|
|
316
|
+
formulation: f ? (f.name || f.uid) : '',
|
|
317
|
+
value: m.value,
|
|
318
|
+
unit: m.unit,
|
|
319
|
+
spec: m.acceptanceCriteria || null,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const ranked = [...byParam.entries()]
|
|
327
|
+
.map(([param, agg]) => ({
|
|
328
|
+
parameter: param,
|
|
329
|
+
totalMeasured: agg.total,
|
|
330
|
+
failureCount: agg.failures,
|
|
331
|
+
failureRate: agg.total ? +(agg.failures / agg.total * 100).toFixed(2) : 0,
|
|
332
|
+
examples: agg.examples,
|
|
333
|
+
}))
|
|
334
|
+
.filter(r => r.failureCount > 0)
|
|
335
|
+
.sort((a, b) => b.failureCount - a.failureCount)
|
|
336
|
+
.slice(0, limitParams);
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
totalParamsConsidered: byParam.size,
|
|
340
|
+
paramsWithFailures: ranked.length,
|
|
341
|
+
ranked,
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// Minimal pass/fail evaluator — mirrors FormLab's _evalPassFail logic
|
|
347
|
+
// for scalar numeric measurements with min/max/target acceptance.
|
|
348
|
+
function _evalPassFail(m) {
|
|
349
|
+
if (!m || m.value == null || m.value === '') return 'untested';
|
|
350
|
+
const spec = m.acceptanceCriteria;
|
|
351
|
+
if (!spec) return 'no-criteria';
|
|
352
|
+
const v = parseFloat(m.value);
|
|
353
|
+
if (!isFinite(v)) {
|
|
354
|
+
// Non-numeric required value
|
|
355
|
+
if (spec.requiredValue != null) {
|
|
356
|
+
return String(m.value).trim() === String(spec.requiredValue).trim() ? 'pass' : 'fail';
|
|
357
|
+
}
|
|
358
|
+
return 'no-criteria';
|
|
359
|
+
}
|
|
360
|
+
if (spec.target != null) return Math.abs(v - parseFloat(spec.target)) <= (spec.tolerance ?? 0) ? 'pass' : 'fail';
|
|
361
|
+
if (spec.min != null && v < parseFloat(spec.min)) return 'fail';
|
|
362
|
+
if (spec.max != null && v > parseFloat(spec.max)) return 'fail';
|
|
363
|
+
return 'pass';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const get_coverage_matrix = {
|
|
367
|
+
definition: {
|
|
368
|
+
name: 'get_coverage_matrix',
|
|
369
|
+
description: 'Coverage map: which formulations have been tested on which parameters. Returns counts per (formulation × parameter) cell so the LLM can answer "what\'s untested" or "what\'s been measured many times" questions.',
|
|
370
|
+
inputSchema: {
|
|
371
|
+
type: 'object',
|
|
372
|
+
properties: {
|
|
373
|
+
formulation_ids: { type: 'array', items: { type: 'string' }, description: 'Restrict to specific formulation ids/UIDs.' },
|
|
374
|
+
parameter_filter: { type: 'string', description: 'Case-insensitive substring; restrict parameter columns.' },
|
|
375
|
+
format: { type: 'string', enum: ['csv', 'json'], description: 'Output format. Default csv.' },
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
handler: async (args) => {
|
|
380
|
+
const { db, indexes } = getStore();
|
|
381
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
382
|
+
const fmt = args.format || 'csv';
|
|
383
|
+
let allowed = null;
|
|
384
|
+
if (Array.isArray(args.formulation_ids) && args.formulation_ids.length) {
|
|
385
|
+
const ids = args.formulation_ids.map(i => resolveById('formulations', i)?.id).filter(Boolean);
|
|
386
|
+
allowed = new Set(ids);
|
|
387
|
+
}
|
|
388
|
+
const counts = new Map(); // formId -> Map(param -> count)
|
|
389
|
+
(db.testResults || []).forEach(t => {
|
|
390
|
+
if (!t || t._trashed) return;
|
|
391
|
+
const samp = indexes.samplesById.get(t.sampleId);
|
|
392
|
+
const formId = samp?.formulationId;
|
|
393
|
+
if (!formId) return;
|
|
394
|
+
if (allowed && !allowed.has(formId)) return;
|
|
395
|
+
if (!counts.has(formId)) counts.set(formId, new Map());
|
|
396
|
+
const inner = counts.get(formId);
|
|
397
|
+
(t.measurements || []).forEach(m => {
|
|
398
|
+
if (!m || !m.parameter) return;
|
|
399
|
+
if (args.parameter_filter && !norm(m.parameter).includes(norm(args.parameter_filter))) return;
|
|
400
|
+
inner.set(m.parameter, (inner.get(m.parameter) || 0) + 1);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
const allParams = new Set();
|
|
404
|
+
counts.forEach(inner => inner.forEach((_, p) => allParams.add(p)));
|
|
405
|
+
const paramCols = [...allParams].sort();
|
|
406
|
+
const formRows = [...counts.entries()].map(([fid, inner]) => {
|
|
407
|
+
const f = indexes.formulationsById.get(fid);
|
|
408
|
+
return { formulation: f?.name || f?.uid || fid, formulationId: fid, ...Object.fromEntries(paramCols.map(p => [p, inner.get(p) || 0])) };
|
|
409
|
+
});
|
|
410
|
+
if (fmt === 'json') {
|
|
411
|
+
return { formulationCount: formRows.length, parameterCount: paramCols.length, parameters: paramCols, rows: formRows };
|
|
412
|
+
}
|
|
413
|
+
const esc = (v) => /[",\n\r]/.test(String(v || '')) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v ?? '');
|
|
414
|
+
const lines = [['Formulation', ...paramCols].map(esc).join(',')];
|
|
415
|
+
formRows.forEach(r => lines.push([r.formulation, ...paramCols.map(p => r[p])].map(esc).join(',')));
|
|
416
|
+
return `# Coverage Matrix · ${formRows.length} formulations × ${paramCols.length} parameters\n` + lines.join('\n');
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
export const tools = {
|
|
421
|
+
list_test_results,
|
|
422
|
+
get_test_result,
|
|
423
|
+
get_doe_matrix,
|
|
424
|
+
find_failures,
|
|
425
|
+
get_coverage_matrix,
|
|
426
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// FORMULATION TOOLS
|
|
3
|
+
// - list_formulations: filtered list
|
|
4
|
+
// - get_formulation: full record with composition + sub-formula flatten
|
|
5
|
+
// - find_similar_formulations: ingredient-pattern search
|
|
6
|
+
// - compare_formulations: pairwise composition diff
|
|
7
|
+
// ============================================================
|
|
8
|
+
|
|
9
|
+
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
10
|
+
|
|
11
|
+
// Strip db-internal noise (createdAt, updatedAt) for cleaner LLM output
|
|
12
|
+
// while keeping the human-facing fields the user cares about.
|
|
13
|
+
function _trimFormulation(f) {
|
|
14
|
+
if (!f) return null;
|
|
15
|
+
return {
|
|
16
|
+
id: f.id,
|
|
17
|
+
uid: f.uid,
|
|
18
|
+
name: f.name,
|
|
19
|
+
description: f.description || '',
|
|
20
|
+
status: f.status || 'Draft',
|
|
21
|
+
family: f.family || '',
|
|
22
|
+
application: f.application || '',
|
|
23
|
+
owner: f.owner || '',
|
|
24
|
+
regulatoryCategory: f.regulatoryCategory || '',
|
|
25
|
+
tags: f.tags || [],
|
|
26
|
+
projectId: f.projectId || null,
|
|
27
|
+
compositionRowCount: (f.composition || []).length,
|
|
28
|
+
hasSubFormulas: (f.composition || []).some(c => c && c.formulationId),
|
|
29
|
+
updatedAt: f.updatedAt || null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const list_formulations = {
|
|
34
|
+
definition: {
|
|
35
|
+
name: 'list_formulations',
|
|
36
|
+
description: 'List formulations (recipes) in the FormLab database, optionally filtered by status, project, family, tag, or name pattern. Returns one row per formula with key metadata. Use get_formulation for the full composition.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
status: { type: 'string', description: 'Filter by status (e.g. "Approved", "In Development"). Case-insensitive substring match.' },
|
|
41
|
+
project: { type: 'string', description: 'Filter by project name or id.' },
|
|
42
|
+
family: { type: 'string', description: 'Filter by formula family (e.g. "Acrylic Coating", "Skin Serum"). Case-insensitive substring.' },
|
|
43
|
+
tag: { type: 'string', description: 'Filter to formulas containing this tag.' },
|
|
44
|
+
name_contains: { type: 'string', description: 'Case-insensitive substring match on formula name.' },
|
|
45
|
+
limit: { type: 'number', description: 'Max rows to return (default 50, max 500).' },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
handler: async (args) => {
|
|
50
|
+
const { db, indexes } = getStore();
|
|
51
|
+
const limit = Math.min(500, Math.max(1, args.limit || 50));
|
|
52
|
+
const all = (db.formulations || []).filter(f => f && !f._trashed);
|
|
53
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
54
|
+
const filtered = all.filter(f => {
|
|
55
|
+
if (args.status && !norm(f.status).includes(norm(args.status))) return false;
|
|
56
|
+
if (args.project) {
|
|
57
|
+
const p = indexes.projectsById.get(args.project)
|
|
58
|
+
|| (db.projects || []).find(x => norm(x.name) === norm(args.project));
|
|
59
|
+
if (!p || f.projectId !== p.id) return false;
|
|
60
|
+
}
|
|
61
|
+
if (args.family && !norm(f.family).includes(norm(args.family))) return false;
|
|
62
|
+
if (args.tag && !(f.tags || []).some(t => norm(t) === norm(args.tag))) return false;
|
|
63
|
+
if (args.name_contains && !norm(f.name).includes(norm(args.name_contains))) return false;
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
totalMatching: filtered.length,
|
|
68
|
+
returned: Math.min(filtered.length, limit),
|
|
69
|
+
formulations: filtered.slice(0, limit).map(_trimFormulation),
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const get_formulation = {
|
|
75
|
+
definition: {
|
|
76
|
+
name: 'get_formulation',
|
|
77
|
+
description: 'Get full details for a single formulation including its composition (raw rows AND flattened wt-% leaves with sub-formulas expanded), step procedure, and recent batches. Accepts either internal id or UID (e.g. FORM-001).',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
id: { type: 'string', description: 'Internal id or UID (FORM-001) of the formulation.' },
|
|
82
|
+
},
|
|
83
|
+
required: ['id'],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
handler: async (args) => {
|
|
87
|
+
const { db } = getStore();
|
|
88
|
+
const f = resolveById('formulations', args.id);
|
|
89
|
+
if (!f) return { error: `No formulation found for id "${args.id}".` };
|
|
90
|
+
const flat = flattenComposition(f);
|
|
91
|
+
const flatRows = flat.rows.map(r => ({
|
|
92
|
+
ingredientId: r.ingredientId,
|
|
93
|
+
ingredient: r.ingredientName,
|
|
94
|
+
role: r.role || '',
|
|
95
|
+
wtPct: +(r.weightFraction * 100).toFixed(4),
|
|
96
|
+
})).sort((a, b) => b.wtPct - a.wtPct);
|
|
97
|
+
const batches = (db.batches || []).filter(b => b.formulationId === f.id);
|
|
98
|
+
const samples = (db.samples || []).filter(s => s.formulationId === f.id);
|
|
99
|
+
return {
|
|
100
|
+
..._trimFormulation(f),
|
|
101
|
+
compositionRaw: (f.composition || []).map(c => ({
|
|
102
|
+
ingredientId: c.ingredientId || null,
|
|
103
|
+
subFormulationId: c.formulationId || null,
|
|
104
|
+
amount: c.amount,
|
|
105
|
+
unit: c.unit,
|
|
106
|
+
role: c.role || '',
|
|
107
|
+
})),
|
|
108
|
+
compositionFlattenedWtPct: flatRows,
|
|
109
|
+
stepCount: (f.steps || []).length,
|
|
110
|
+
batchCount: batches.length,
|
|
111
|
+
sampleCount: samples.length,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const find_similar_formulations = {
|
|
117
|
+
definition: {
|
|
118
|
+
name: 'find_similar_formulations',
|
|
119
|
+
description: 'Find formulations whose flattened composition contains a given ingredient (by name or id) above a threshold wt-%. Useful for "what other formulas use silicone oil at ≥5%" queries. Returns ranked by wt-% descending.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
ingredient: { type: 'string', description: 'Ingredient name (case-insensitive substring) or id/UID.' },
|
|
124
|
+
min_wt_pct: { type: 'number', description: 'Minimum wt-% (0-100). Default 0.1.' },
|
|
125
|
+
limit: { type: 'number', description: 'Max formulas to return. Default 20.' },
|
|
126
|
+
},
|
|
127
|
+
required: ['ingredient'],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
handler: async (args) => {
|
|
131
|
+
const { db } = getStore();
|
|
132
|
+
const limit = Math.min(200, Math.max(1, args.limit || 20));
|
|
133
|
+
const minPct = args.min_wt_pct ?? 0.1;
|
|
134
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
135
|
+
const q = norm(args.ingredient);
|
|
136
|
+
// Resolve target ingredient(s) — substring match on name, plus exact match on id/UID
|
|
137
|
+
const targets = (db.ingredients || []).filter(i =>
|
|
138
|
+
i && (i.id === args.ingredient || i.uid === args.ingredient || norm(i.name).includes(q))
|
|
139
|
+
);
|
|
140
|
+
if (!targets.length) return { matches: [], note: `No ingredient matched "${args.ingredient}".` };
|
|
141
|
+
const targetIds = new Set(targets.map(i => i.id));
|
|
142
|
+
const hits = [];
|
|
143
|
+
(db.formulations || []).forEach(f => {
|
|
144
|
+
if (!f || f._trashed) return;
|
|
145
|
+
const flat = flattenComposition(f);
|
|
146
|
+
flat.rows.forEach(r => {
|
|
147
|
+
if (!targetIds.has(r.ingredientId)) return;
|
|
148
|
+
const pct = r.weightFraction * 100;
|
|
149
|
+
if (pct < minPct) return;
|
|
150
|
+
hits.push({
|
|
151
|
+
formulationId: f.id,
|
|
152
|
+
formulationUid: f.uid,
|
|
153
|
+
formulationName: f.name,
|
|
154
|
+
ingredient: r.ingredientName,
|
|
155
|
+
wtPct: +pct.toFixed(4),
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
hits.sort((a, b) => b.wtPct - a.wtPct);
|
|
160
|
+
return {
|
|
161
|
+
targets: targets.map(t => ({ id: t.id, uid: t.uid, name: t.name })),
|
|
162
|
+
matchCount: hits.length,
|
|
163
|
+
matches: hits.slice(0, limit),
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const compare_formulations = {
|
|
169
|
+
definition: {
|
|
170
|
+
name: 'compare_formulations',
|
|
171
|
+
description: 'Compare two or more formulations side-by-side on the same set of leaf ingredients (flattened wt-%). Returns one row per ingredient with each formula\'s wt-% in its own column. Useful for "what\'s different between Formula A and Formula B" questions.',
|
|
172
|
+
inputSchema: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
ids: { type: 'array', items: { type: 'string' }, description: 'Formula ids or UIDs (e.g. ["FORM-001", "FORM-005"]).' },
|
|
176
|
+
},
|
|
177
|
+
required: ['ids'],
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
handler: async (args) => {
|
|
181
|
+
if (!Array.isArray(args.ids) || args.ids.length < 2) return { error: 'Pass at least 2 formula ids.' };
|
|
182
|
+
const formulas = args.ids.map(id => resolveById('formulations', id)).filter(Boolean);
|
|
183
|
+
if (formulas.length < 2) return { error: 'Fewer than 2 of the supplied ids resolved to existing formulas.' };
|
|
184
|
+
const colByForm = formulas.map(f => {
|
|
185
|
+
const flat = flattenComposition(f);
|
|
186
|
+
const m = new Map();
|
|
187
|
+
flat.rows.forEach(r => m.set(r.ingredientId, { name: r.ingredientName, pct: +(r.weightFraction * 100).toFixed(4) }));
|
|
188
|
+
return { formula: f, map: m };
|
|
189
|
+
});
|
|
190
|
+
const allIngIds = new Set();
|
|
191
|
+
colByForm.forEach(c => c.map.forEach((_, k) => allIngIds.add(k)));
|
|
192
|
+
const rows = [...allIngIds].map(ingId => {
|
|
193
|
+
const ref = colByForm.find(c => c.map.get(ingId))?.map.get(ingId);
|
|
194
|
+
const out = { ingredientId: ingId, ingredient: ref?.name || ingId };
|
|
195
|
+
colByForm.forEach(c => { out[c.formula.uid || c.formula.id] = c.map.get(ingId)?.pct ?? null; });
|
|
196
|
+
// Add deltaMax: max - min wt-% across formulas, sortable so caller can find biggest differences fast
|
|
197
|
+
const vals = colByForm.map(c => c.map.get(ingId)?.pct ?? 0);
|
|
198
|
+
out.deltaMax = +(Math.max(...vals) - Math.min(...vals)).toFixed(4);
|
|
199
|
+
return out;
|
|
200
|
+
}).sort((a, b) => b.deltaMax - a.deltaMax);
|
|
201
|
+
return {
|
|
202
|
+
formulas: formulas.map(f => ({ id: f.id, uid: f.uid, name: f.name })),
|
|
203
|
+
ingredientCount: rows.length,
|
|
204
|
+
rows,
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const tools = {
|
|
210
|
+
list_formulations,
|
|
211
|
+
get_formulation,
|
|
212
|
+
find_similar_formulations,
|
|
213
|
+
compare_formulations,
|
|
214
|
+
};
|