formlab-mcp 0.6.13 → 0.6.15
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 +3 -1
- package/SUBMISSION.md +1 -1
- package/index.js +3 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/batch-analyze.js +234 -0
- package/tools/ingredients.js +3 -0
- package/tools/lab.js +8 -0
package/README.md
CHANGED
|
@@ -45,6 +45,8 @@ Ask Claude (or another MCP client) things like:
|
|
|
45
45
|
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
|
|
46
46
|
| `find_failures` | Pareto-style: parameters that fail acceptance most often |
|
|
47
47
|
| `get_coverage_matrix` | Which formulations × parameters have been measured (TEST coverage) |
|
|
48
|
+
| `compare_batches` | Reproducibility of 2+ runs (ideally of one formula): each run's **yield %**, actual produced mass, cost/kg; per-ingredient **drift** across the runs vs the formula's proposed wt-%; and the biggest **outlier** run. `basis`: `wt_percent` (default) or `amount` |
|
|
49
|
+
| `get_batch_pivot` | Production analytics — aggregate batches by `group_by` (formula / status / month / prepared_by / project) × `metric` (count, avg_yield_pct, total_produced_kg, avg_cost_per_batch, total_samples, total_tests), with share + total for additive metrics. Optional `batch_uids` scope + `status` filter |
|
|
48
50
|
| `get_stability` | Grounded stability / shelf-life analysis for a formulation, sample or batch × parameter (auto-detected): time-series, drift (per-month slope + R²), an I-chart (mean ±3σ + out-of-control points), spec status, and an ICH-Q1E-flavored projected shelf life (point + 95%-CI crossing), split by storage condition |
|
|
49
51
|
| `get_design_space` | COMPOSITION design space for one project: which ingredients vary and over what observed wt-% ranges (ranked), an occupied-region summary ("where you've been"), the biggest untested **interior gap** as ready-to-seed DOE ranges (a combination you could have made but skipped), and an optional standardised **2-component PCA** of all varying ingredients — loadings, variance-explained, scree, and a full-dimensional gap. Matches the in-app Design Space Viewer exactly. Answers *"where haven't I explored?"* / *"what should I formulate next?"* |
|
|
50
52
|
| `list_doe_designs` | Saved DOE designs — the *recipe* behind each batch of runs: design type, factors + ranges, constraints, run count, D-efficiency. Filters: `project`, `design_type`, `constrained_only`, `name_contains` |
|
|
@@ -146,7 +148,7 @@ Or for local-dev (from the repo):
|
|
|
146
148
|
}
|
|
147
149
|
```
|
|
148
150
|
|
|
149
|
-
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's
|
|
151
|
+
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 36 tools become callable in any conversation.
|
|
150
152
|
|
|
151
153
|
## Wire it up to Claude Code
|
|
152
154
|
|
package/SUBMISSION.md
CHANGED
|
@@ -47,7 +47,7 @@ _"what parameters fail acceptance most often?"_, or _"show me the DOE matrix
|
|
|
47
47
|
at sample grain for Anti-Aging Serum family"_ — all against a JSON export
|
|
48
48
|
that stays on the user's laptop.
|
|
49
49
|
|
|
50
|
-
**
|
|
50
|
+
**36 read-only tools** covering formulations, ingredients, batches, samples,
|
|
51
51
|
test results, DOE matrices + saved designs, similarity, failure analysis,
|
|
52
52
|
test-coverage AND composition design-space mapping (varying ingredients,
|
|
53
53
|
untested gaps, PCA), equipment, test methods, panels, projects and ELN notes.
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// FORMLAB_EXPORT=/path/to/export.json node index.js
|
|
8
8
|
// formlab-mcp /path/to/export.json (when installed via npm)
|
|
9
9
|
//
|
|
10
|
-
// Exposes
|
|
10
|
+
// Exposes 36 tools (see ./tools/) over stdio. The MCP host (Claude
|
|
11
11
|
// Desktop, Claude Code, etc.) handles tool discovery, invocation
|
|
12
12
|
// and response formatting. We just register handlers and stay out
|
|
13
13
|
// of the way.
|
|
@@ -37,6 +37,7 @@ import * as designspace from './tools/designspace.js';
|
|
|
37
37
|
import * as library from './tools/library.js';
|
|
38
38
|
import * as projects from './tools/projects.js';
|
|
39
39
|
import * as notebook from './tools/notebook.js';
|
|
40
|
+
import * as batchAnalyze from './tools/batch-analyze.js';
|
|
40
41
|
|
|
41
42
|
// ----- Data source: LIVE cloud workspace OR a static export file -----
|
|
42
43
|
// Cloud mode kicks in when a dedicated read-only MCP token is present (mint it
|
|
@@ -116,6 +117,7 @@ const TOOLS = [
|
|
|
116
117
|
...Object.values(library.tools),
|
|
117
118
|
...Object.values(projects.tools),
|
|
118
119
|
...Object.values(notebook.tools),
|
|
120
|
+
...Object.values(batchAnalyze.tools),
|
|
119
121
|
];
|
|
120
122
|
|
|
121
123
|
// Version reported in the MCP handshake. Read from package.json rather than
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.15",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
5
|
"description": "Read-only Model Context Protocol server for FormLab — lets Claude (and other MCP clients) read and analyze your FormLab data, from a local export file OR your live cloud workspace.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.juliu1980/formlab-mcp",
|
|
4
4
|
"description": "Read-only MCP for FormLab — let Claude query your formulation lab. Free reads a local export; Pro connects to your live cloud workspace with a dedicated read-only token.",
|
|
5
|
-
"version": "0.6.
|
|
5
|
+
"version": "0.6.15",
|
|
6
6
|
"websiteUrl": "https://formvix.com/mcp",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/juliu1980/FormLab",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "formlab-mcp",
|
|
16
|
-
"version": "0.6.
|
|
16
|
+
"version": "0.6.15",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Batch analysis tools (read-only) — parity with the in-app Batch Analyze panel
|
|
3
|
+
// (js/views/batches-compare.js). Spec: docs/MCP_BATCH_ANALYZE_SPEC.md.
|
|
4
|
+
// - compare_batches: reproducibility of N runs (yield / produced / cost,
|
|
5
|
+
// per-ingredient drift vs the proposal, outlier run).
|
|
6
|
+
// - get_batch_pivot: production analytics (group_by × metric).
|
|
7
|
+
// Computes from the SAME primitives as the app so numbers match: flattened
|
|
8
|
+
// wt-% (data.js flattenComposition), _batchActualMassKg (tools/lab.js),
|
|
9
|
+
// _costPerKg (tools/ingredients.js).
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
13
|
+
import { _batchActualMassKg, _massToKg } from './lab.js';
|
|
14
|
+
import { _costPerKg } from './ingredients.js';
|
|
15
|
+
|
|
16
|
+
// ---- shared compute (mirrors _bcActualPctMap / _bcProposedPctMap / _bcSpreadLevel) ----
|
|
17
|
+
function _pctMap(compositionHost) {
|
|
18
|
+
const out = new Map();
|
|
19
|
+
if (!compositionHost) return out;
|
|
20
|
+
const flat = flattenComposition(compositionHost);
|
|
21
|
+
for (const r of (flat.rows || [])) {
|
|
22
|
+
if (!r.ingredientId) continue;
|
|
23
|
+
out.set(r.ingredientId, (out.get(r.ingredientId) || 0) + r.weightFraction * 100);
|
|
24
|
+
}
|
|
25
|
+
for (const [k, v] of out) out.set(k, +v.toFixed(4));
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
const _actualPctMap = b => (Array.isArray(b.actualComposition) && b.actualComposition.length)
|
|
29
|
+
? _pctMap({ composition: b.actualComposition }) : new Map();
|
|
30
|
+
const _proposedPctMap = f => f ? _pctMap(f) : new Map();
|
|
31
|
+
|
|
32
|
+
// Actual amount (as entered) per ingredient — top-level rows only, {amount, unit}.
|
|
33
|
+
function _amountMap(b) {
|
|
34
|
+
const out = new Map();
|
|
35
|
+
for (const c of (b.actualComposition || [])) {
|
|
36
|
+
if (!c || !c.ingredientId) continue;
|
|
37
|
+
const amt = parseFloat(c.amount);
|
|
38
|
+
if (!isFinite(amt)) continue;
|
|
39
|
+
const prev = out.get(c.ingredientId);
|
|
40
|
+
if (!prev) out.set(c.ingredientId, { amount: amt, unit: c.unit || '' });
|
|
41
|
+
else out.set(c.ingredientId, { amount: prev.amount + amt, unit: prev.unit });
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _targetKg(b) { const q = parseFloat(b.targetQuantity); return isFinite(q) ? _massToKg(q, b.unit) : null; }
|
|
47
|
+
function _yieldPct(b) { const tk = _targetKg(b), mk = _batchActualMassKg(b); return (tk && tk > 0 && mk != null) ? +(mk / tk * 100).toFixed(1) : null; }
|
|
48
|
+
|
|
49
|
+
// Blend cost per kg = Σ ingredient costPerKg × weightFraction over the flattened
|
|
50
|
+
// composition (actual if logged, else the linked formula). Purity gross-up and
|
|
51
|
+
// currency conversion the app applies are NOT modeled here — this is a
|
|
52
|
+
// currency-per-kg estimate in each ingredient's own cost currency.
|
|
53
|
+
function _batchCostPerKg(b, formula) {
|
|
54
|
+
const db = getStore().db;
|
|
55
|
+
const host = (Array.isArray(b.actualComposition) && b.actualComposition.length) ? { composition: b.actualComposition } : formula;
|
|
56
|
+
if (!host) return null;
|
|
57
|
+
const flat = flattenComposition(host);
|
|
58
|
+
let sum = 0, any = false;
|
|
59
|
+
for (const r of (flat.rows || [])) {
|
|
60
|
+
const ing = (db.ingredients || []).find(i => i.id === r.ingredientId);
|
|
61
|
+
const cpk = ing ? _costPerKg(ing) : null;
|
|
62
|
+
if (cpk != null) { sum += cpk * r.weightFraction; any = true; }
|
|
63
|
+
}
|
|
64
|
+
return any ? +sum.toFixed(4) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Drift severity across a run of values. wt%: absolute + relative thresholds;
|
|
68
|
+
// amount: relative-only (units differ). 0 consistent, 1 minor, 2 large.
|
|
69
|
+
function _spreadLevel(vals, relOnly) {
|
|
70
|
+
if (!vals || vals.length < 2) return { level: 0, spread: 0 };
|
|
71
|
+
const max = Math.max(...vals), min = Math.min(...vals), spread = max - min;
|
|
72
|
+
const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
73
|
+
const rel = mean > 0 ? spread / mean : (spread > 0 ? 1 : 0);
|
|
74
|
+
let level = 0;
|
|
75
|
+
if (relOnly) { if (rel >= 0.15) level = 2; else if (rel >= 0.05) level = 1; }
|
|
76
|
+
else { if (spread >= 1 || rel >= 0.15) level = 2; else if (spread >= 0.25 || rel >= 0.05) level = 1; }
|
|
77
|
+
return { level, spread: +spread.toFixed(4) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _batchTestCount(b, db) {
|
|
81
|
+
const sIds = new Set((db.samples || []).filter(s => s.batchId === b.id).map(s => s.id));
|
|
82
|
+
return (db.testResults || []).filter(t => sIds.has(t.sampleId)).length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================
|
|
86
|
+
// compare_batches
|
|
87
|
+
// ============================================================
|
|
88
|
+
const compare_batches = {
|
|
89
|
+
definition: {
|
|
90
|
+
name: 'compare_batches',
|
|
91
|
+
description: 'Compare 2+ batches (ideally runs of ONE formula) for reproducibility: each run\'s yield %, actual produced mass, and cost/kg; per-ingredient drift across the runs vs the formula\'s proposed wt-%; and the biggest outlier run. Best for "are these runs consistent / which drifted / which is the outlier". basis="wt_percent" (default, size-normalized) or "amount" (as-entered).',
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
batch_uids: { type: 'array', items: { type: 'string' }, description: 'Batch ids or UIDs (e.g. ["BATCH-0017","BATCH-0019"]). 2 or more.' },
|
|
96
|
+
basis: { type: 'string', enum: ['wt_percent', 'amount'], description: 'wt_percent (default) or amount (as entered, top-level rows).' },
|
|
97
|
+
},
|
|
98
|
+
required: ['batch_uids'],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
handler: async (args) => {
|
|
102
|
+
if (!Array.isArray(args.batch_uids) || args.batch_uids.length < 2) return { error: 'Pass at least 2 batch ids/uids.' };
|
|
103
|
+
const db = getStore().db;
|
|
104
|
+
const batches = args.batch_uids.map(id => resolveById('batches', id)).filter(Boolean);
|
|
105
|
+
if (batches.length < 2) return { error: 'Fewer than 2 of the supplied ids resolved to existing batches.' };
|
|
106
|
+
const basis = args.basis === 'amount' ? 'amount' : 'wt_percent';
|
|
107
|
+
|
|
108
|
+
const cols = batches.map(b => {
|
|
109
|
+
const f = (db.formulations || []).find(x => x.id === b.formulationId) || null;
|
|
110
|
+
const hasActual = Array.isArray(b.actualComposition) && b.actualComposition.length > 0;
|
|
111
|
+
const mk = _batchActualMassKg(b);
|
|
112
|
+
return { b, f, hasActual, actMap: _actualPctMap(b), amtMap: _amountMap(b),
|
|
113
|
+
yield_pct: _yieldPct(b), produced_kg: mk != null ? +mk.toFixed(4) : null, cost_per_kg: _batchCostPerKg(b, f) };
|
|
114
|
+
});
|
|
115
|
+
const fIds = new Set(batches.map(b => b.formulationId));
|
|
116
|
+
const shared = fIds.size === 1 ? cols[0].f : null;
|
|
117
|
+
const proposed = (basis === 'wt_percent' && shared) ? _proposedPctMap(shared) : new Map();
|
|
118
|
+
const colsAct = cols.filter(c => c.hasActual);
|
|
119
|
+
|
|
120
|
+
// ingredient universe
|
|
121
|
+
const ingIds = new Set();
|
|
122
|
+
proposed.forEach((_v, k) => ingIds.add(k));
|
|
123
|
+
cols.forEach(c => (basis === 'amount' ? c.amtMap : c.actMap).forEach((_v, k) => ingIds.add(k)));
|
|
124
|
+
|
|
125
|
+
const composition = [...ingIds].map(ingId => {
|
|
126
|
+
const ing = (db.ingredients || []).find(i => i.id === ingId);
|
|
127
|
+
const runs = {};
|
|
128
|
+
const present = [];
|
|
129
|
+
let unit = null, sameUnit = true;
|
|
130
|
+
colsAct.forEach(c => {
|
|
131
|
+
const uid = c.b.uid || c.b.id;
|
|
132
|
+
if (basis === 'amount') {
|
|
133
|
+
const e = c.amtMap.get(ingId);
|
|
134
|
+
if (e) { runs[uid] = +(+e.amount).toFixed(4); present.push(e.amount); if (unit == null) unit = e.unit; else if (unit !== e.unit) sameUnit = false; }
|
|
135
|
+
} else {
|
|
136
|
+
if (c.actMap.has(ingId)) { runs[uid] = c.actMap.get(ingId); present.push(c.actMap.get(ingId)); }
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
const sev = (present.length >= 2 && (basis === 'wt_percent' || sameUnit)) ? _spreadLevel(present, basis === 'amount') : { level: 0, spread: 0 };
|
|
140
|
+
const row = { ingredient: ing?.name || ingId, cas: ing?.cas || ing?.casNumber || '', runs, spread: sev.spread, drift_level: sev.level };
|
|
141
|
+
if (basis === 'wt_percent') row.proposed_wt_pct = proposed.has(ingId) ? proposed.get(ingId) : null;
|
|
142
|
+
if (basis === 'amount' && unit) row.unit = unit;
|
|
143
|
+
return row;
|
|
144
|
+
}).sort((a, b) => b.drift_level - a.drift_level || b.spread - a.spread || String(a.ingredient).localeCompare(b.ingredient));
|
|
145
|
+
|
|
146
|
+
// outlier: max summed |Δ from cross-run mean| leading the runner-up by >15% (wt% basis, 3+ runs)
|
|
147
|
+
let outlier_uid = null;
|
|
148
|
+
if (basis === 'wt_percent' && colsAct.length >= 3) {
|
|
149
|
+
const union = new Set(); colsAct.forEach(c => c.actMap.forEach((_v, k) => union.add(k)));
|
|
150
|
+
const mean = new Map();
|
|
151
|
+
union.forEach(id => { const p = colsAct.filter(c => c.actMap.has(id)); if (p.length >= 2) mean.set(id, p.reduce((s, c) => s + c.actMap.get(id), 0) / p.length); });
|
|
152
|
+
const scores = colsAct.map(c => { let s = 0; mean.forEach((mv, id) => { if (c.actMap.has(id)) s += Math.abs(c.actMap.get(id) - mv); }); return { uid: c.b.uid || c.b.id, s }; }).sort((a, b) => b.s - a.s);
|
|
153
|
+
if (scores.length >= 2 && scores[0].s > 0 && scores[0].s > scores[1].s * 1.15) outlier_uid = scores[0].uid;
|
|
154
|
+
}
|
|
155
|
+
const drifting = composition.filter(r => r.drift_level >= 1).length;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
runs: cols.map(c => ({
|
|
159
|
+
uid: c.b.uid || c.b.id, formula: (c.f && c.f.name) || c.b.formulationName || null,
|
|
160
|
+
status: c.b.status || null, prepared_date: c.b.preparedDate || null, prepared_by: c.b.preparedBy || null,
|
|
161
|
+
target: c.b.targetQuantity != null ? { qty: c.b.targetQuantity, unit: c.b.unit || null } : null,
|
|
162
|
+
actual_produced_kg: c.produced_kg, yield_pct: c.yield_pct, cost_per_kg: c.cost_per_kg,
|
|
163
|
+
has_actual_composition: c.hasActual, is_outlier: (c.b.uid || c.b.id) === outlier_uid,
|
|
164
|
+
})),
|
|
165
|
+
basis, shared_formula: shared ? shared.name : null,
|
|
166
|
+
composition,
|
|
167
|
+
summary: { runs_with_actuals: colsAct.length, drifting_ingredients: drifting, outlier_uid, consistent: colsAct.length >= 2 && drifting === 0 },
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ============================================================
|
|
173
|
+
// get_batch_pivot
|
|
174
|
+
// ============================================================
|
|
175
|
+
const _GROUP_LABEL = { formula: 'Formula', status: 'Status', month: 'Month', prepared_by: 'Prepared by', project: 'Project' };
|
|
176
|
+
const _METRIC_LABEL = { count: 'Batch count', avg_yield_pct: 'Avg yield %', total_produced_kg: 'Total produced (kg)', avg_cost_per_batch: 'Avg cost / batch', total_samples: 'Total samples', total_tests: 'Total tests' };
|
|
177
|
+
|
|
178
|
+
function _groupKey(b, groupBy, db) {
|
|
179
|
+
if (groupBy === 'status') return b.status || '(no status)';
|
|
180
|
+
if (groupBy === 'month') return (b.preparedDate || '').slice(0, 7) || '(no date)';
|
|
181
|
+
if (groupBy === 'prepared_by') return b.preparedBy || '(unknown)';
|
|
182
|
+
if (groupBy === 'project') { const f = (db.formulations || []).find(x => x.id === b.formulationId); const p = f && (db.projects || []).find(pp => pp.id === f.projectId); return p ? (p.name || '(project)') : '(no project)'; }
|
|
183
|
+
return b.formulationName || ((db.formulations || []).find(f => f.id === b.formulationId) || {}).name || '(no formula)';
|
|
184
|
+
}
|
|
185
|
+
function _metricVal(bs, metric, db) {
|
|
186
|
+
if (metric === 'count') return bs.length;
|
|
187
|
+
if (metric === 'avg_yield_pct') { const ys = bs.map(_yieldPct).filter(v => v != null); return ys.length ? +(ys.reduce((a, b) => a + b, 0) / ys.length).toFixed(1) : null; }
|
|
188
|
+
if (metric === 'total_produced_kg') { let kg = 0, any = false; bs.forEach(b => { const m = _batchActualMassKg(b); if (m != null && isFinite(m)) { kg += m; any = true; } }); return any ? +kg.toFixed(3) : null; }
|
|
189
|
+
if (metric === 'avg_cost_per_batch') { const cs = bs.map(b => { const f = (db.formulations || []).find(x => x.id === b.formulationId) || null; const perKg = _batchCostPerKg(b, f); const mk = _batchActualMassKg(b) || _targetKg(b); return (perKg != null && mk != null) ? perKg * mk : null; }).filter(v => v != null); return cs.length ? +(cs.reduce((a, b) => a + b, 0) / cs.length).toFixed(2) : null; }
|
|
190
|
+
if (metric === 'total_samples') return bs.reduce((s, b) => s + (db.samples || []).filter(sm => sm.batchId === b.id).length, 0);
|
|
191
|
+
if (metric === 'total_tests') return bs.reduce((s, b) => s + _batchTestCount(b, db), 0);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const get_batch_pivot = {
|
|
196
|
+
definition: {
|
|
197
|
+
name: 'get_batch_pivot',
|
|
198
|
+
description: 'Aggregate batches by a dimension (formula / status / month / prepared_by / project) and a batch-native metric (count, avg_yield_pct, total_produced_kg, avg_cost_per_batch, total_samples, total_tests). The "lab-manager" production view. Optionally scope to specific batch_uids and/or a status filter.',
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: 'object',
|
|
201
|
+
properties: {
|
|
202
|
+
group_by: { type: 'string', enum: ['formula', 'status', 'month', 'prepared_by', 'project'], description: 'Grouping dimension (default formula).' },
|
|
203
|
+
metric: { type: 'string', enum: ['count', 'avg_yield_pct', 'total_produced_kg', 'avg_cost_per_batch', 'total_samples', 'total_tests'], description: 'Metric per group (default count).' },
|
|
204
|
+
batch_uids: { type: 'array', items: { type: 'string' }, description: 'Optional — scope to these batches; omit for all.' },
|
|
205
|
+
status: { type: 'string', description: 'Optional status filter (e.g. "Completed").' },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
handler: async (args) => {
|
|
210
|
+
const db = getStore().db;
|
|
211
|
+
const groupBy = _GROUP_LABEL[args.group_by] ? args.group_by : 'formula';
|
|
212
|
+
const metric = _METRIC_LABEL[args.metric] ? args.metric : 'count';
|
|
213
|
+
let batches = db.batches || [];
|
|
214
|
+
let scope = 'all';
|
|
215
|
+
if (Array.isArray(args.batch_uids) && args.batch_uids.length) {
|
|
216
|
+
const ids = new Set(args.batch_uids.map(u => { const b = resolveById('batches', u); return b ? b.id : null; }).filter(Boolean));
|
|
217
|
+
batches = batches.filter(b => ids.has(b.id));
|
|
218
|
+
scope = 'selected';
|
|
219
|
+
}
|
|
220
|
+
if (args.status) batches = batches.filter(b => String(b.status || '').toLowerCase() === String(args.status).toLowerCase());
|
|
221
|
+
|
|
222
|
+
const groups = {};
|
|
223
|
+
batches.forEach(b => { const k = _groupKey(b, groupBy, db); (groups[k] = groups[k] || []).push(b); });
|
|
224
|
+
const additive = ['count', 'total_produced_kg', 'total_samples', 'total_tests'].includes(metric);
|
|
225
|
+
const rows = Object.entries(groups).map(([key, bs]) => ({ key, batches: bs.length, value: _metricVal(bs, metric, db) }))
|
|
226
|
+
.sort((a, b) => (b.value == null ? -Infinity : b.value) - (a.value == null ? -Infinity : a.value) || a.key.localeCompare(b.key));
|
|
227
|
+
const total = additive ? +rows.reduce((s, r) => s + (r.value || 0), 0).toFixed(3) : null;
|
|
228
|
+
if (additive && total) rows.forEach(r => { r.share_pct = +((r.value || 0) / total * 100).toFixed(1); });
|
|
229
|
+
|
|
230
|
+
return { group_by: groupBy, group_by_label: _GROUP_LABEL[groupBy], metric, metric_label: _METRIC_LABEL[metric], scope, batch_count: batches.length, additive, total, rows };
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export const tools = { compare_batches, get_batch_pivot };
|
package/tools/ingredients.js
CHANGED
|
@@ -415,3 +415,6 @@ const list_inventory = {
|
|
|
415
415
|
};
|
|
416
416
|
|
|
417
417
|
export const tools = { list_ingredients, get_ingredient, find_by_smarts, list_lots, list_inventory };
|
|
418
|
+
|
|
419
|
+
// Exported for the batch-analyze tools (avg cost metric) to reuse the same cost normalization.
|
|
420
|
+
export { _costPerKg, _densityKgPerL };
|
package/tools/lab.js
CHANGED
|
@@ -189,6 +189,11 @@ const get_batch = {
|
|
|
189
189
|
..._trimBatch(b, indexes.formulationsById),
|
|
190
190
|
notes: b.notes || '',
|
|
191
191
|
equipmentUsed: b.equipmentUsed || '',
|
|
192
|
+
// Multi-select equipment: full list [{name, equipmentId}]; falls back to
|
|
193
|
+
// the legacy single equipmentUsed so older batches still report one item.
|
|
194
|
+
equipmentUsedList: (Array.isArray(b.equipmentUsedList) && b.equipmentUsedList.length)
|
|
195
|
+
? b.equipmentUsedList.filter(e => e && e.name).map(e => ({ name: e.name, equipmentId: e.equipmentId || null }))
|
|
196
|
+
: (b.equipmentUsed ? [{ name: b.equipmentUsed, equipmentId: b.equipmentUsedId || null }] : []),
|
|
192
197
|
actualCompositionRowCount: Array.isArray(b.actualComposition) ? b.actualComposition.length : 0,
|
|
193
198
|
hasActualComposition: Array.isArray(b.actualComposition) && b.actualComposition.length > 0,
|
|
194
199
|
sourceBlendParents: parents,
|
|
@@ -296,3 +301,6 @@ const get_sample = {
|
|
|
296
301
|
};
|
|
297
302
|
|
|
298
303
|
export const tools = { list_batches, get_batch, list_samples, get_sample };
|
|
304
|
+
|
|
305
|
+
// Exported for the batch-analyze tools so they reuse the SAME mass logic.
|
|
306
|
+
export { _batchActualMassKg, _massToKg };
|