formlab-mcp 0.6.24 → 0.6.26
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 +7 -6
- package/index.js +3 -1
- package/package.json +1 -1
- package/peptide.js +65 -0
- package/server.json +2 -2
- package/tools/analytics.js +285 -79
- package/tools/documents.js +165 -0
- package/tools/formulations.js +2 -0
- package/tools/ingredients.js +29 -6
- package/tools/library.js +4 -4
package/README.md
CHANGED
|
@@ -34,19 +34,20 @@ Ask Claude (or another MCP client) things like:
|
|
|
34
34
|
| `compare_formulations` | Pairwise side-by-side composition diff |
|
|
35
35
|
| `list_ingredients` | Filtered list of raw materials. Filters: `family`, `supplier`, `name_contains`, `in_stock_only`, `ingredient_class` (small-molecule / surfactant / polymer / extract / fragrance / pigment / sequence / mixture), `sequence_contains` (e.g. `KTTKS` → Matrixyl), `taxon_contains` (e.g. `Centella`) |
|
|
36
36
|
| `get_ingredient` | Full record + supplier / **cost ($/kg)** / stock / chemical name / molecular weight / storage / formulations using it, plus **GHS safety** (pictograms, H/P codes, signal word), **per-jurisdiction regulatory status**, **approved sources** (the suppliers / manufacturers qualified to supply this material — ASL/AML), and **inventory lots** (balances + expiry + the source each was received from). Returns every class-specific sub-object when present: `sequence` (peptide / oligo), `taxon` (NCBI ID + scientific name), `ingredientClass`, `extractDetails`, `sequenceDetails`, `polymerDetails`, `surfactantDetails`, `pigmentDetails`, `fragranceDetails` |
|
|
37
|
-
| `list_lots` | Inventory lots across ingredients (each a received batch with its own remaining balance, supplier, expiry, **unit cost**, **status** — `active` / `hold` / `quarantine` / `rejected`; held lots are blocked from consumption, with a `holdReason` — and the **approved source** it was received from: `source.supplier` + `source.manufacturer`, where recalls hinge on the manufacturer). Filters: `ingredient_id`, `expiring_within_days`, `status` (e.g. `hold` to find lots held for a recall) |
|
|
37
|
+
| `list_lots` | Inventory lots across ingredients (each a received batch with its own remaining balance, supplier, expiry, **unit cost**, **status** — `active` / `hold` / `quarantine` / `rejected`; held lots are blocked from consumption, with a `holdReason` — and the **approved source** it was received from: `source.supplier` + `source.manufacturer`, where recalls hinge on the manufacturer). Each lot also reports its **Certificate of Analysis** (`coa`: linked, or matched by lot number) and `coaMissing` for an in-stock lot without one. Filters: `ingredient_id`, `expiring_within_days`, `status` (e.g. `hold` to find lots held for a recall), `missing_coa` |
|
|
38
38
|
| `list_suppliers` | Supplier / manufacturer records with roll-ups — ingredients approved, lots received / on hand (+ value at paid cost), qualified / trial / disqualified source counts, last receipt; lots count by supplier **and** by manufacturer |
|
|
39
39
|
| `get_supplier` | One supplier in full: record, every ingredient it's an approved source for, every lot from it, and the **recall trace** (batches that consumed those lots → products, shipments, samples, sub-batches; lots on hold flagged) |
|
|
40
|
+
| `list_documents` | The document register (same as the app's Documents page): ingredient SDS / PDS / CoA / other with lot, version, source and expiry; test and notebook attachments; per-record uploads and links (formulas, batches, samples, suppliers, equipment…). File **metadata** only, never the text inside a file. Filters: `type`, `record`, `linked`, `query`, `lot`, `format`, `expiry`, `expiring_within_days`, `without_expiry`, `uploaded_within_days`. A `summary` block carries the page's tile counts: expired, expiring in 30 days, SDS/CoA without expiry, ingredients missing an SDS (+ coverage %), lots in stock without a CoA, added last 7 days |
|
|
40
41
|
| `list_inventory` | Portfolio stock rollup — one row per ingredient with on-hand qty, summed lot balance, nearest expiry, and a low-stock flag (on-hand ≤ `reorderThreshold`, or zero). Filters: `low_stock_only`, `expiring_within_days`, `family` |
|
|
41
42
|
| `list_batches` | Filtered list of production / lab-prep events |
|
|
42
43
|
| `get_batch` | Full record + actual composition, measured/derived **actual volume** (mass ÷ density, never a sum of per-ingredient volumes) and **actual mass** (yield, else summed as-prepared inputs — mL rows through that ingredient's density — else measured volume × density), **estimatedVolume** (mass ÷ the formula's finished density when no actual exists, flagged `estimate: true`), **plannedMass** for volume-target batches (target × finished density) and **formulaFinishedDensity**, the **process** method + any **process-factor values** (DOE inputs), samples + blend lineage, and — as a finished-goods lot — its storage **`location`** + finished-goods **`stockLedger`** (shipments out + adjustments; `shippedQty` total) |
|
|
43
44
|
| `list_samples` | Filtered list of physical specimens |
|
|
44
45
|
| `get_sample` | Full record + canonical variant + test reports + blend lineage |
|
|
45
46
|
| `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
|
|
46
|
-
| `get_test_result` | Full report: every measurement's value, spec, any per-measurement run **conditions**, and the **resolved instrument** (`instrumentSource`: row / method / run). Complex types carry a `representative` (time-series → final, distribution → D50); a time series adds `aggregated` (per-timestamp mean ± sd) and `pointReplicates` (repeat readings at one `t`). Report-level `seriesReplicates` lists parameters measured 2+ times as separate rows — point vs series replicates are distinct |
|
|
47
|
+
| `get_test_result` | Full report: every measurement's value, spec, any per-measurement run **conditions**, and the **resolved instrument** (`instrumentSource`: row / method / run). Complex types carry a `representative` (time-series → final, distribution → D50); a time series adds `aggregated` (per-timestamp mean ± sd) and `pointReplicates` (repeat readings at one `t`). Report-level `seriesReplicates` lists parameters measured 2+ times as separate rows — point vs series replicates are distinct. Each measurement also gives its **effective spec** (`effectiveSpec`, `specSource`: override / template / parameter / none) and `passFail` — panel and Test Method specs apply exactly as in the app, and every pass/fail count across the tools uses the same rule |
|
|
47
48
|
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
|
|
48
|
-
| `find_failures` |
|
|
49
|
-
| `get_coverage_matrix` |
|
|
49
|
+
| `find_failures` | Parameters ranked by failure **rate** (failing ÷ all readings; replicates in one report count once), `lowN` flag under 5 readings, plus formulations ranked by share of failing reports — same numbers as the app's Test Analytics → Failures |
|
|
50
|
+
| `get_coverage_matrix` | TEST coverage by formulation, batch or sample (`grain`) × parameter. Each cell is the **latest** reading by test date (same-day reports averaged, any fail → fail) with its date and how many reports measured it — same rollup as the app's Test Analytics → Coverage |
|
|
50
51
|
| `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` |
|
|
51
52
|
| `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 |
|
|
52
53
|
| `get_project_pivot` | Portfolio analytics — aggregate projects by `group_by` (status / phase / priority / business_unit / site / customer / lead, or `cf:<custom field>`) × `metric` (count, total_formulas / batches / samples, total_cost, avg_formulas / batches / cost_per_batch), rolling child activity + cost up by any project attribute. Optional `project_uids` scope |
|
|
@@ -54,7 +55,7 @@ Ask Claude (or another MCP client) things like:
|
|
|
54
55
|
| `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?"* |
|
|
55
56
|
| `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` |
|
|
56
57
|
| `get_doe_design` | One design in full: constraints in plain language, the model it was optimised for, generation settings (run budget, replicates, centre points, seed) and the complete run matrix with the formulation each run became |
|
|
57
|
-
| `find_by_smarts` | SMARTS-pattern substructure search across every ingredient with a SMILES. Requires `@rdkit/rdkit` (optional dependency — install with `npm install @rdkit/rdkit` in `mcp/` if you get a "not installed" error). Examples: `c1ccccc1` (any aromatic 6-ring), `[OX2H1]` (any hydroxyl), `C(=O)O` (carboxylic acid), `[F,Cl,Br,I]` (any halogen). |
|
|
58
|
+
| `find_by_smarts` | SMARTS-pattern substructure search across every ingredient with a SMILES; peptides that only store a sequence are searched with the structure derived from it, as the app draws them (`smilesDerived: true`). Requires `@rdkit/rdkit` (optional dependency — install with `npm install @rdkit/rdkit` in `mcp/` if you get a "not installed" error). Examples: `c1ccccc1` (any aromatic 6-ring), `[OX2H1]` (any hydroxyl), `C(=O)O` (carboxylic acid), `[F,Cl,Br,I]` (any halogen). |
|
|
58
59
|
| `list_equipment` | Filtered list of the Equipment registry (mixers, ovens, viscometers, balances…). Filters: `category`, `status`, `manufacturer`, `name_contains` |
|
|
59
60
|
| `get_equipment` | Full equipment record + `usedIn` (panels, step presets, test methods, formula steps, batches, testReports resolved to it), `usage` (reports / formulas / batches / samples / pass rate / top formulas) and `measured` (per parameter: mean here vs mean on other instruments, biasPct) |
|
|
60
61
|
| `list_test_methods` | Filtered list of Test Methods (the parameter library). Filter by `analyte` (the property measured, e.g. `Viscosity` — finds every method for it), `category`, `name_contains`, `include_retired` |
|
|
@@ -151,7 +152,7 @@ Or for local-dev (from the repo):
|
|
|
151
152
|
}
|
|
152
153
|
```
|
|
153
154
|
|
|
154
|
-
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's
|
|
155
|
+
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 40 tools become callable in any conversation.
|
|
155
156
|
|
|
156
157
|
## Wire it up to Claude Code
|
|
157
158
|
|
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 40 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.
|
|
@@ -39,6 +39,7 @@ import * as projects from './tools/projects.js';
|
|
|
39
39
|
import * as notebook from './tools/notebook.js';
|
|
40
40
|
import * as batchAnalyze from './tools/batch-analyze.js';
|
|
41
41
|
import * as suppliers from './tools/suppliers.js';
|
|
42
|
+
import * as documents from './tools/documents.js';
|
|
42
43
|
|
|
43
44
|
// ----- Data source: LIVE cloud workspace OR a static export file -----
|
|
44
45
|
// Cloud mode kicks in when a dedicated read-only MCP token is present (mint it
|
|
@@ -119,6 +120,7 @@ const TOOLS = [
|
|
|
119
120
|
...Object.values(projects.tools),
|
|
120
121
|
...Object.values(notebook.tools),
|
|
121
122
|
...Object.values(suppliers.tools),
|
|
123
|
+
...Object.values(documents.tools),
|
|
122
124
|
...Object.values(batchAnalyze.tools),
|
|
123
125
|
];
|
|
124
126
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.26",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
5
|
"description": "Read-only Model Context Protocol server for FormLab \u2014 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/peptide.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Peptide sequence → SMILES, ported from the app's js/peptide-compute.js
|
|
2
|
+
// (peptideToSmiles, 2026-09-14) so find_by_smarts sees peptides that only
|
|
3
|
+
// store a sequence, exactly as the app draws them. Keep the two in step:
|
|
4
|
+
// H-[NH-CHR-CO]n-OH, L-amino acids, Pro / 4-Hyp as ring residues, known
|
|
5
|
+
// terminal modifications; unknown tokens are skipped, never invented.
|
|
6
|
+
|
|
7
|
+
const RESIDUES = new Set('ARNDCEQGHILKMFPSTWYVO'.split('')); // O = 4-hydroxyproline (collagen)
|
|
8
|
+
const TOKEN_ALIASES = { hyp: 'O' };
|
|
9
|
+
const NTERM_MODS = ['Pal', 'Ac', 'Boc', 'Fmoc', 'Myr', 'Bz'];
|
|
10
|
+
const CTERM_MODS = ['NH2', 'OMe'];
|
|
11
|
+
|
|
12
|
+
const SIDECHAIN = {
|
|
13
|
+
A: 'C', R: 'CCCNC(=N)N', N: 'CC(N)=O', D: 'CC(=O)O', C: 'CS', E: 'CCC(=O)O', Q: 'CCC(N)=O',
|
|
14
|
+
H: 'Cc1c[nH]cn1', I: '[C@@H](C)CC', L: 'CC(C)C', K: 'CCCCN', M: 'CCSC', F: 'Cc1ccccc1',
|
|
15
|
+
S: 'CO', T: '[C@H](O)C', W: 'Cc1c[nH]c2ccccc12', Y: 'Cc1ccc(O)cc1', V: 'C(C)C',
|
|
16
|
+
};
|
|
17
|
+
const FRAGMENT = {
|
|
18
|
+
G: 'NCC(=O)',
|
|
19
|
+
P: 'N1CCC[C@H]1C(=O)',
|
|
20
|
+
O: 'N1C[C@H](O)C[C@H]1C(=O)',
|
|
21
|
+
};
|
|
22
|
+
const NTERM_SMILES = {
|
|
23
|
+
Ac: 'CC(=O)', Pal: 'CCCCCCCCCCCCCCCC(=O)', Myr: 'CCCCCCCCCCCCCC(=O)', Bz: 'c1ccccc1C(=O)',
|
|
24
|
+
Boc: 'CC(C)(C)OC(=O)', Fmoc: 'O=C(OCC1c2ccccc2-c2ccccc12)',
|
|
25
|
+
};
|
|
26
|
+
const CTERM_SMILES = { NH2: 'N', OMe: 'OC' };
|
|
27
|
+
|
|
28
|
+
const tokenize = (raw) => (typeof raw === 'string' && raw) ? raw.split('-').map(s => s.trim()).filter(Boolean) : [];
|
|
29
|
+
function residues(tokens) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const tok of tokens) {
|
|
32
|
+
const alias = TOKEN_ALIASES[tok.toLowerCase()];
|
|
33
|
+
if (alias) { out.push(alias); continue; }
|
|
34
|
+
if (/^[A-Z]+$/.test(tok)) for (const ch of tok) if (RESIDUES.has(ch)) out.push(ch);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function modAt(tokens, idx, list, minLen) {
|
|
39
|
+
if (tokens.length < minLen) return null;
|
|
40
|
+
const tok = tokens[idx];
|
|
41
|
+
if (/^[A-Z]+$/.test(tok)) return null;
|
|
42
|
+
return list.find(k => k.toLowerCase() === tok.toLowerCase()) || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function peptideToSmiles(rawSequence) {
|
|
46
|
+
const tokens = tokenize(rawSequence);
|
|
47
|
+
const aas = residues(tokens);
|
|
48
|
+
if (!aas.length) return null;
|
|
49
|
+
const body = aas.map(aa => FRAGMENT[aa] || `N[C@@H](${SIDECHAIN[aa]})C(=O)`).join('');
|
|
50
|
+
const n = modAt(tokens, 0, NTERM_MODS, 1);
|
|
51
|
+
const c = modAt(tokens, tokens.length - 1, CTERM_MODS, 2);
|
|
52
|
+
return (n ? NTERM_SMILES[n] : '') + body + (c ? CTERM_SMILES[c] : 'O');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The SMILES an ingredient is searched with: stored SMILES, else (non-oligo
|
|
56
|
+
// sequence) the derived peptide SMILES. Returns { smiles, derived }.
|
|
57
|
+
export function ingredientSmiles(ing) {
|
|
58
|
+
if (ing && ing.smiles) return { smiles: ing.smiles, derived: false };
|
|
59
|
+
const seq = ing && ing.sequence;
|
|
60
|
+
if (seq && seq.raw && seq.type !== 'oligo') {
|
|
61
|
+
const s = peptideToSmiles(seq.raw);
|
|
62
|
+
if (s) return { smiles: s, derived: true };
|
|
63
|
+
}
|
|
64
|
+
return { smiles: '', derived: false };
|
|
65
|
+
}
|
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.26",
|
|
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.26",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -111,6 +111,9 @@ function _trimMeasurement(m, t, db) {
|
|
|
111
111
|
binCount: Array.isArray(m.bins) ? m.bins.length : 0,
|
|
112
112
|
...complex,
|
|
113
113
|
acceptanceCriteria: m.acceptanceCriteria || null,
|
|
114
|
+
// Effective spec + where it came from (override | template | parameter | none)
|
|
115
|
+
// and the app's pass/fail verdict under it.
|
|
116
|
+
...(() => { const r = _resolveSpec(m, t && t.templateId, db); return { effectiveSpec: r.spec || null, specSource: r.source, passFail: _evalPassFail(m, t && t.templateId, db) }; })(),
|
|
114
117
|
// Captured run conditions this reading was measured under (e.g.
|
|
115
118
|
// {Storage: "40 °C / 75% RH"}, {RPM: "50"}) — the condition travels with the
|
|
116
119
|
// value, so a reading is self-describing and comparable across runs. Present
|
|
@@ -383,10 +386,65 @@ const get_doe_matrix = {
|
|
|
383
386
|
},
|
|
384
387
|
};
|
|
385
388
|
|
|
389
|
+
// ---- Shared reading flattener (mirrors the app's _taFlattenMeasurements) ----
|
|
390
|
+
// One row per reading with a representative number (scalar → value,
|
|
391
|
+
// time_series → final point mean, distribution → D50) and pass/fail. Replicates
|
|
392
|
+
// — the same parameter measured more than once in ONE report under the SAME run
|
|
393
|
+
// conditions — collapse into a single timepoint (numeric mean, any fail → fail),
|
|
394
|
+
// so a parameter measured 3× on one occasion counts once in failure rates and
|
|
395
|
+
// coverage, exactly as Test Analytics does.
|
|
396
|
+
const FAIL_MIN_N = 5; // app: _TA_FAIL_MIN_N — rates on fewer readings are "low n"
|
|
397
|
+
function _mcpRepNumber(m) {
|
|
398
|
+
const type = m.type || 'scalar';
|
|
399
|
+
if (type === 'time_series' && Array.isArray(m.points) && m.points.length) {
|
|
400
|
+
const agg = _mcpTsAggregate(m.points);
|
|
401
|
+
const last = agg[agg.length - 1];
|
|
402
|
+
return last ? last.mean : null;
|
|
403
|
+
}
|
|
404
|
+
if (type === 'distribution' && Array.isArray(m.bins) && m.bins.length) {
|
|
405
|
+
const d50 = m.bins.find(b => String(b.label || '').toUpperCase().replace(/\s/g, '') === 'D50');
|
|
406
|
+
const v = d50 ? parseFloat(d50.value) : NaN;
|
|
407
|
+
return isFinite(v) ? v : null;
|
|
408
|
+
}
|
|
409
|
+
const v = parseFloat(m.value);
|
|
410
|
+
return isFinite(v) ? v : null;
|
|
411
|
+
}
|
|
412
|
+
function _mcpCondSig(m) {
|
|
413
|
+
const c = m && m.conditions;
|
|
414
|
+
if (!c || typeof c !== 'object') return '';
|
|
415
|
+
return Object.keys(c).filter(k => c[k] != null && c[k] !== '').sort().map(k => `${k}=${c[k]}`).join('|');
|
|
416
|
+
}
|
|
417
|
+
function _mcpFlattenReadings(tests, indexes, paramFilter) {
|
|
418
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
419
|
+
const groups = new Map(); const order = [];
|
|
420
|
+
for (const t of tests) {
|
|
421
|
+
const samp = indexes.samplesById.get(t.sampleId) || null;
|
|
422
|
+
const formulation = samp && samp.formulationId ? indexes.formulationsById.get(samp.formulationId) || null : null;
|
|
423
|
+
for (const m of (t.measurements || [])) {
|
|
424
|
+
if (!m) continue;
|
|
425
|
+
const parameter = String(m.parameter || '').trim();
|
|
426
|
+
if (!parameter) continue;
|
|
427
|
+
if (paramFilter && !norm(parameter).includes(norm(paramFilter))) continue;
|
|
428
|
+
const row = { test: t, sample: samp, formulation, parameter, numeric: _mcpRepNumber(m), value: m.value, unit: m.unit || '', pf: _evalPassFail(m, t.templateId), m };
|
|
429
|
+
const key = (t.id || t.uid) + '|' + parameter + '|' + _mcpCondSig(m);
|
|
430
|
+
if (!groups.has(key)) { groups.set(key, []); order.push(key); }
|
|
431
|
+
groups.get(key).push(row);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return order.map(k => {
|
|
435
|
+
const g = groups.get(k);
|
|
436
|
+
if (g.length === 1) return g[0];
|
|
437
|
+
const nums = g.map(r => r.numeric).filter(v => v != null && isFinite(v));
|
|
438
|
+
const mean = nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
|
|
439
|
+
const pf = g.some(r => r.pf === 'fail') ? 'fail' : g.some(r => r.pf === 'pass') ? 'pass' : g[0].pf;
|
|
440
|
+
return { ...g[0], numeric: mean, value: mean != null ? mean : g[0].value, pf, replicates: g.length };
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
386
444
|
const find_failures = {
|
|
387
445
|
definition: {
|
|
388
446
|
name: 'find_failures',
|
|
389
|
-
description: '
|
|
447
|
+
description: 'Failure analysis matching the app\'s Test Analytics → Failures tab: test parameters ranked by failure RATE (failing ÷ all readings, replicates in one report under the same run conditions counted once), worst rate first, count breaking ties. Rates on fewer than 5 readings carry lowN: true and are tentative. Also ranks formulations by the share of their test reports that fail. Returns ranked (parameter, totalMeasured, failureCount, failureRate %, lowN, exampleFailures) plus formulations.',
|
|
390
448
|
inputSchema: {
|
|
391
449
|
type: 'object',
|
|
392
450
|
properties: {
|
|
@@ -403,38 +461,30 @@ const find_failures = {
|
|
|
403
461
|
const limitExamples = Math.min(20, Math.max(1, args.limit_examples_per_param || 5));
|
|
404
462
|
const norm = (s) => String(s || '').toLowerCase();
|
|
405
463
|
|
|
406
|
-
|
|
464
|
+
const tests = (db.testResults || []).filter(t => t && !t._trashed && !(args.since_date && (t.testDate || '') < args.since_date));
|
|
465
|
+
const flat = _mcpFlattenReadings(tests, indexes, args.parameter);
|
|
466
|
+
|
|
467
|
+
// Per-parameter aggregator (over timepoints, like the app)
|
|
407
468
|
const byParam = new Map();
|
|
408
|
-
(
|
|
409
|
-
if (!
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
testDate: t.testDate || '',
|
|
428
|
-
sample: samp ? (samp.uid || samp.id) : '',
|
|
429
|
-
formulation: f ? (f.name || f.uid) : '',
|
|
430
|
-
value: m.value,
|
|
431
|
-
unit: m.unit,
|
|
432
|
-
spec: m.acceptanceCriteria || null,
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
});
|
|
437
|
-
});
|
|
469
|
+
for (const r of flat) {
|
|
470
|
+
if (!byParam.has(r.parameter)) byParam.set(r.parameter, { total: 0, failures: 0, examples: [] });
|
|
471
|
+
const agg = byParam.get(r.parameter);
|
|
472
|
+
agg.total += 1;
|
|
473
|
+
if (r.pf !== 'fail') continue;
|
|
474
|
+
agg.failures += 1;
|
|
475
|
+
if (agg.examples.length < limitExamples) {
|
|
476
|
+
agg.examples.push({
|
|
477
|
+
testId: r.test.uid || r.test.id,
|
|
478
|
+
testDate: r.test.testDate || '',
|
|
479
|
+
sample: r.sample ? (r.sample.uid || r.sample.id) : '',
|
|
480
|
+
formulation: r.formulation ? (r.formulation.name || r.formulation.uid) : '',
|
|
481
|
+
value: r.value,
|
|
482
|
+
unit: r.unit,
|
|
483
|
+
spec: _resolveSpec(r.m, r.test.templateId).spec || null,
|
|
484
|
+
...(r.replicates ? { replicatesAveraged: r.replicates } : {}),
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
438
488
|
|
|
439
489
|
const ranked = [...byParam.entries()]
|
|
440
490
|
.map(([param, agg]) => ({
|
|
@@ -442,49 +492,134 @@ const find_failures = {
|
|
|
442
492
|
totalMeasured: agg.total,
|
|
443
493
|
failureCount: agg.failures,
|
|
444
494
|
failureRate: agg.total ? +(agg.failures / agg.total * 100).toFixed(2) : 0,
|
|
495
|
+
lowN: agg.total < FAIL_MIN_N,
|
|
445
496
|
examples: agg.examples,
|
|
446
497
|
}))
|
|
447
498
|
.filter(r => r.failureCount > 0)
|
|
448
|
-
.sort((a, b) => b.failureCount - a.failureCount)
|
|
499
|
+
.sort((a, b) => b.failureRate - a.failureRate || b.failureCount - a.failureCount)
|
|
449
500
|
.slice(0, limitParams);
|
|
450
501
|
|
|
502
|
+
// Formulations by share of failing test reports (a report fails when any
|
|
503
|
+
// of its readings fails).
|
|
504
|
+
const byForm = new Map();
|
|
505
|
+
const failingTests = new Set(flat.filter(r => r.pf === 'fail').map(r => r.test));
|
|
506
|
+
for (const t of tests) {
|
|
507
|
+
const samp = indexes.samplesById.get(t.sampleId);
|
|
508
|
+
const f = samp && samp.formulationId ? indexes.formulationsById.get(samp.formulationId) : null;
|
|
509
|
+
if (!f) continue;
|
|
510
|
+
const cur = byForm.get(f.id) || { formulation: f.name || f.uid, formulationId: f.uid || f.id, reports: 0, failingReports: 0 };
|
|
511
|
+
cur.reports += 1;
|
|
512
|
+
if (failingTests.has(t)) cur.failingReports += 1;
|
|
513
|
+
byForm.set(f.id, cur);
|
|
514
|
+
}
|
|
515
|
+
const formulations = [...byForm.values()]
|
|
516
|
+
.filter(r => r.failingReports > 0)
|
|
517
|
+
.map(r => ({ ...r, failureRate: +(r.failingReports / r.reports * 100).toFixed(2), lowN: r.reports < FAIL_MIN_N }))
|
|
518
|
+
.sort((a, b) => b.failureRate - a.failureRate || b.failingReports - a.failingReports)
|
|
519
|
+
.slice(0, 8);
|
|
520
|
+
|
|
451
521
|
return {
|
|
452
522
|
totalParamsConsidered: byParam.size,
|
|
453
523
|
paramsWithFailures: ranked.length,
|
|
524
|
+
rateBasis: 'failing readings ÷ all readings of the parameter; replicates in one report under the same run conditions count once; lowN = fewer than 5 readings',
|
|
454
525
|
ranked,
|
|
526
|
+
formulations,
|
|
455
527
|
};
|
|
456
528
|
},
|
|
457
529
|
};
|
|
458
530
|
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (!
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
531
|
+
// Effective acceptance spec for a reading — mirrors the app's _resolveSpec
|
|
532
|
+
// (js/views/testing.js): the reading's own spec ('override'), else the test
|
|
533
|
+
// panel's row for that parameter ('template'; matched by a LIVE parameterId,
|
|
534
|
+
// else by trimmed name), else the Test Method library default ('parameter').
|
|
535
|
+
function _resolveSpec(m, templateId, db) {
|
|
536
|
+
if (!m) return { spec: null, source: 'none' };
|
|
537
|
+
if (m.acceptanceCriteria) return { spec: m.acceptanceCriteria, source: 'override' };
|
|
538
|
+
const store = db || getStore().db;
|
|
539
|
+
if (templateId) {
|
|
540
|
+
const tmpl = (store.templates || []).find(t => t && t.id === templateId);
|
|
541
|
+
const pid = m.parameterId || '';
|
|
542
|
+
const name = String(m.parameter || '').trim();
|
|
543
|
+
const pidLive = !!(pid && (store.parameters || []).some(p => p && p.id === pid));
|
|
544
|
+
const row = ((tmpl && tmpl.parameters) || []).find(p =>
|
|
545
|
+
(pidLive && p.parameterId === pid) ||
|
|
546
|
+
(!pidLive && String((p.parameter || p.name) || '').trim() === name));
|
|
547
|
+
if (row && row.acceptanceCriteria) return { spec: row.acceptanceCriteria, source: 'template' };
|
|
548
|
+
}
|
|
549
|
+
if (m.parameterId) {
|
|
550
|
+
const lib = (store.parameters || []).find(p => p && p.id === m.parameterId);
|
|
551
|
+
if (lib && lib.acceptanceCriteria) return { spec: lib.acceptanceCriteria, source: 'parameter' };
|
|
552
|
+
}
|
|
553
|
+
return { spec: null, source: 'none' };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const _MEAS_TYPES = ['scalar', 'ordinal', 'categorical', 'time_series', 'distribution'];
|
|
557
|
+
function _measType(m) {
|
|
558
|
+
if (!m) return 'scalar';
|
|
559
|
+
if (m.type && _MEAS_TYPES.includes(m.type)) return m.type;
|
|
560
|
+
return m.valueType === 'Categorical' ? 'categorical' : 'scalar';
|
|
561
|
+
}
|
|
562
|
+
function _ordinalMinRank(crit, scale) {
|
|
563
|
+
if (!crit) return null;
|
|
564
|
+
if (crit.minValue != null) { const i = (Array.isArray(scale) ? scale : []).indexOf(crit.minValue); return i >= 0 ? i : null; }
|
|
565
|
+
return crit.minRank != null ? crit.minRank : null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Pass / fail for one reading — mirrors the app's _evalPassFail per type.
|
|
569
|
+
// Pass the parent test's templateId so panel specs apply (as the app does).
|
|
570
|
+
// Returns 'pass' | 'fail' | 'no-criteria' | 'untested'.
|
|
571
|
+
function _evalPassFail(m, templateId, db) {
|
|
572
|
+
const crit = _resolveSpec(m, templateId, db).spec;
|
|
573
|
+
if (!crit) return 'no-criteria';
|
|
574
|
+
const type = _measType(m);
|
|
575
|
+
const raw = String(m?.value ?? '').trim();
|
|
576
|
+
if (type === 'scalar') {
|
|
577
|
+
if (!raw) return 'untested';
|
|
578
|
+
const v = parseFloat(raw);
|
|
579
|
+
if (!isFinite(v)) return 'untested';
|
|
580
|
+
if (crit.target != null && v !== crit.target) return 'fail';
|
|
581
|
+
if (crit.min != null && v < crit.min) return 'fail';
|
|
582
|
+
if (crit.max != null && v > crit.max) return 'fail';
|
|
583
|
+
return 'pass';
|
|
584
|
+
}
|
|
585
|
+
if (type === 'ordinal') {
|
|
586
|
+
if (!raw) return 'untested';
|
|
587
|
+
const scale = Array.isArray(m.scale) ? m.scale : [];
|
|
588
|
+
const idx = scale.indexOf(raw);
|
|
589
|
+
if (idx < 0) return 'untested';
|
|
590
|
+
const minRank = _ordinalMinRank(crit, scale);
|
|
591
|
+
if (minRank == null) return 'no-criteria';
|
|
592
|
+
return idx >= minRank ? 'pass' : 'fail';
|
|
593
|
+
}
|
|
594
|
+
if (type === 'categorical') {
|
|
595
|
+
if (!raw) return 'untested';
|
|
596
|
+
if (!crit.requiredValue) return 'no-criteria';
|
|
597
|
+
return raw.toLowerCase() === String(crit.requiredValue).toLowerCase() ? 'pass' : 'fail';
|
|
598
|
+
}
|
|
599
|
+
if (type === 'time_series' || type === 'distribution') {
|
|
600
|
+
const list = type === 'time_series' ? (Array.isArray(m.points) ? m.points : []) : (Array.isArray(m.bins) ? m.bins : []);
|
|
601
|
+
if (!list.length) return 'untested';
|
|
602
|
+
for (const p of list) {
|
|
603
|
+
const v = parseFloat(p.value);
|
|
604
|
+
if (!isFinite(v)) return 'untested';
|
|
605
|
+
if (crit.min != null && v < crit.min) return 'fail';
|
|
606
|
+
if (crit.max != null && v > crit.max) return 'fail';
|
|
470
607
|
}
|
|
471
|
-
return '
|
|
608
|
+
return 'pass';
|
|
472
609
|
}
|
|
473
|
-
|
|
474
|
-
if (spec.min != null && v < parseFloat(spec.min)) return 'fail';
|
|
475
|
-
if (spec.max != null && v > parseFloat(spec.max)) return 'fail';
|
|
476
|
-
return 'pass';
|
|
610
|
+
return 'no-criteria';
|
|
477
611
|
}
|
|
478
612
|
|
|
479
613
|
const get_coverage_matrix = {
|
|
480
614
|
definition: {
|
|
481
615
|
name: 'get_coverage_matrix',
|
|
482
|
-
description: 'Coverage
|
|
616
|
+
description: 'Coverage matrix matching the app\'s Test Analytics → Coverage: rows = sample, batch or formulation (grain), columns = parameters. Each cell is the LATEST reading by test date; readings from several reports on that same date are averaged and fail if any of them fails. Each cell also gives latestDate, reportsAveraged and nReports (how many reports measured it). Empty = never tested. Use for "what\'s untested", "what\'s the current value" and "what\'s been measured many times".',
|
|
483
617
|
inputSchema: {
|
|
484
618
|
type: 'object',
|
|
485
619
|
properties: {
|
|
486
620
|
formulation_ids: { type: 'array', items: { type: 'string' }, description: 'Restrict to specific formulation ids/UIDs.' },
|
|
487
621
|
parameter_filter: { type: 'string', description: 'Case-insensitive substring; restrict parameter columns.' },
|
|
622
|
+
grain: { type: 'string', enum: ['formulation', 'batch', 'sample'], description: 'Row level. Default formulation.' },
|
|
488
623
|
format: { type: 'string', enum: ['csv', 'json'], description: 'Output format. Default csv.' },
|
|
489
624
|
},
|
|
490
625
|
},
|
|
@@ -498,38 +633,77 @@ const get_coverage_matrix = {
|
|
|
498
633
|
const ids = args.formulation_ids.map(i => resolveById('formulations', i)?.id).filter(Boolean);
|
|
499
634
|
allowed = new Set(ids);
|
|
500
635
|
}
|
|
501
|
-
const
|
|
502
|
-
(db.testResults || []).
|
|
503
|
-
if (!t || t._trashed) return;
|
|
636
|
+
const grain = ['batch', 'sample'].includes(args.grain) ? args.grain : 'formulation';
|
|
637
|
+
const tests = (db.testResults || []).filter(t => {
|
|
638
|
+
if (!t || t._trashed) return false;
|
|
639
|
+
if (!allowed) return true;
|
|
504
640
|
const samp = indexes.samplesById.get(t.sampleId);
|
|
505
|
-
|
|
506
|
-
if (!formId) return;
|
|
507
|
-
if (allowed && !allowed.has(formId)) return;
|
|
508
|
-
if (!counts.has(formId)) counts.set(formId, new Map());
|
|
509
|
-
const inner = counts.get(formId);
|
|
510
|
-
(t.measurements || []).forEach(m => {
|
|
511
|
-
if (!m || !m.parameter) return;
|
|
512
|
-
if (args.parameter_filter && !norm(m.parameter).includes(norm(args.parameter_filter))) return;
|
|
513
|
-
inner.set(m.parameter, (inner.get(m.parameter) || 0) + 1);
|
|
514
|
-
});
|
|
515
|
-
});
|
|
516
|
-
const allParams = new Set();
|
|
517
|
-
counts.forEach(inner => inner.forEach((_, p) => allParams.add(p)));
|
|
518
|
-
const paramCols = [...allParams].sort();
|
|
519
|
-
const formRows = [...counts.entries()].map(([fid, inner]) => {
|
|
520
|
-
const f = indexes.formulationsById.get(fid);
|
|
521
|
-
return { formulation: f?.name || f?.uid || fid, formulationId: fid, ...Object.fromEntries(paramCols.map(p => [p, inner.get(p) || 0])) };
|
|
641
|
+
return !!(samp && allowed.has(samp.formulationId));
|
|
522
642
|
});
|
|
643
|
+
const flat = _mcpFlattenReadings(tests, indexes, args.parameter_filter);
|
|
644
|
+
const rows = _mcpCoverageRows(flat, grain, indexes);
|
|
645
|
+
const paramSet = new Set();
|
|
646
|
+
rows.forEach(r => r.cells.forEach((_, p) => paramSet.add(p)));
|
|
647
|
+
const paramCols = [...paramSet].sort();
|
|
648
|
+
const sorted = [...rows.values()].sort((a, b) => String(a.label).localeCompare(String(b.label)));
|
|
649
|
+
const grainLabel = grain === 'formulation' ? 'Formulation' : grain === 'batch' ? 'Batch' : 'Sample';
|
|
523
650
|
if (fmt === 'json') {
|
|
524
|
-
return {
|
|
651
|
+
return {
|
|
652
|
+
grain, rowCount: sorted.length, parameterCount: paramCols.length, parameters: paramCols,
|
|
653
|
+
rule: 'cell = latest reading by test date; same-date reports averaged (any fail → fail)',
|
|
654
|
+
rows: sorted.map(r => ({ [grain]: r.label, id: r.uid, cells: Object.fromEntries([...r.cells.entries()].sort()) })),
|
|
655
|
+
};
|
|
525
656
|
}
|
|
526
|
-
const esc = (v) => /[",\n\r]/.test(String(v
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
657
|
+
const esc = (v) => /[",\n\r]/.test(String(v ?? '')) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v ?? '');
|
|
658
|
+
const fmtCell = (c) => {
|
|
659
|
+
if (!c) return '';
|
|
660
|
+
const v = c.value == null ? '' : (typeof c.value === 'number' ? +c.value.toPrecision(6) : c.value);
|
|
661
|
+
const bits = [c.latestDate || '?'];
|
|
662
|
+
if (c.reportsAveraged > 1) bits.push(`avg of ${c.reportsAveraged}`);
|
|
663
|
+
bits.push(`${c.nReports} report${c.nReports === 1 ? '' : 's'}`);
|
|
664
|
+
return `${v}${c.unit ? ' ' + c.unit : ''}${c.pf === 'fail' ? ' FAIL' : c.pf === 'pass' ? ' pass' : ''} (${bits.join(', ')})`;
|
|
665
|
+
};
|
|
666
|
+
const lines = [[grainLabel, ...paramCols].map(esc).join(',')];
|
|
667
|
+
sorted.forEach(r => lines.push([r.label, ...paramCols.map(p => fmtCell(r.cells.get(p)))].map(esc).join(',')));
|
|
668
|
+
return `# Coverage Matrix · ${sorted.length} ${grain === 'batch' ? 'batches' : grain + 's'} × ${paramCols.length} parameters · cell = latest reading (same-day reports averaged)\n` + lines.join('\n');
|
|
530
669
|
},
|
|
531
670
|
};
|
|
532
671
|
|
|
672
|
+
// Latest-date rollup (mirrors the app's _taCoverageRows).
|
|
673
|
+
function _mcpCoverageRows(flat, grain, indexes) {
|
|
674
|
+
const rows = new Map();
|
|
675
|
+
for (const r of flat) {
|
|
676
|
+
let rec = null, label = '';
|
|
677
|
+
if (grain === 'sample') { rec = r.sample; label = rec && (rec.uid || rec.id); }
|
|
678
|
+
else if (grain === 'batch') { rec = r.sample && r.sample.batchId ? indexes.batchesById.get(r.sample.batchId) : null; label = rec && (rec.uid || rec.id); }
|
|
679
|
+
else { rec = r.formulation; label = rec && (rec.name || rec.uid); }
|
|
680
|
+
if (!rec) continue;
|
|
681
|
+
if (!rows.has(rec.id)) rows.set(rec.id, { label: label || rec.id, uid: rec.uid || rec.id, latest: new Map(), reports: new Map() });
|
|
682
|
+
const row = rows.get(rec.id);
|
|
683
|
+
const date = String(r.test.testDate || '');
|
|
684
|
+
if (!row.reports.has(r.parameter)) row.reports.set(r.parameter, new Set());
|
|
685
|
+
row.reports.get(r.parameter).add(r.test.id || r.test.uid);
|
|
686
|
+
const cur = row.latest.get(r.parameter);
|
|
687
|
+
if (!cur || cur.date < date) row.latest.set(r.parameter, { date, list: [r] });
|
|
688
|
+
else if (cur.date === date) cur.list.push(r);
|
|
689
|
+
}
|
|
690
|
+
for (const row of rows.values()) {
|
|
691
|
+
row.cells = new Map();
|
|
692
|
+
for (const [param, { date, list }] of row.latest) {
|
|
693
|
+
const nums = list.map(x => x.numeric).filter(v => v != null && isFinite(v));
|
|
694
|
+
const mean = nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
|
|
695
|
+
const pf = list.some(x => x.pf === 'fail') ? 'fail' : list.some(x => x.pf === 'pass') ? 'pass' : list[0].pf;
|
|
696
|
+
row.cells.set(param, {
|
|
697
|
+
value: list.length === 1 ? (list[0].numeric != null ? list[0].numeric : list[0].value) : (mean != null ? mean : list[0].value),
|
|
698
|
+
unit: list[0].unit, pf: (pf === 'pass' || pf === 'fail') ? pf : null,
|
|
699
|
+
latestDate: date || null, reportsAveraged: list.length, nReports: row.reports.get(param).size,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
delete row.latest; delete row.reports;
|
|
703
|
+
}
|
|
704
|
+
return rows;
|
|
705
|
+
}
|
|
706
|
+
|
|
533
707
|
// ============================================================
|
|
534
708
|
// SAVED DOE DESIGNS
|
|
535
709
|
// ------------------------------------------------------------
|
|
@@ -809,7 +983,24 @@ function _shelfLife(points, spec) {
|
|
|
809
983
|
for (let tt = lastX; tt <= lastX + 120; tt += 0.25) {
|
|
810
984
|
if (fit.slope < 0 ? bound(tt) <= limit : bound(tt) >= limit) { ciRemaining = tt - lastX; break; }
|
|
811
985
|
}
|
|
812
|
-
return { edge, limit, remainingMonths: remaining, ciRemainingMonths: ciRemaining, r2: fit.r2 };
|
|
986
|
+
return { edge, limit, remainingMonths: remaining, ciRemainingMonths: ciRemaining, r2: fit.r2, elapsedMonths: lastX };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Same verdict rule as the app (js/views/stability.js stabShelfLifeAssess):
|
|
990
|
+
// months from T0 vs the target shelf life — short (trend crosses first),
|
|
991
|
+
// marginal (only the 95% bound does), meets; no target → by time left
|
|
992
|
+
// (<3 mo high, <12 mo medium). Nothing past the 5-year horizon is reported.
|
|
993
|
+
function _shelfLifeAssess(sl, targetMonths) {
|
|
994
|
+
if (!sl || !(sl.r2 >= 0.3) || !(sl.remainingMonths > 0.05)) return null;
|
|
995
|
+
const pointTotal = sl.elapsedMonths + sl.remainingMonths;
|
|
996
|
+
const ciTotal = sl.ciRemainingMonths != null ? sl.elapsedMonths + sl.ciRemainingMonths : null;
|
|
997
|
+
if (targetMonths) {
|
|
998
|
+
const verdict = pointTotal < targetMonths ? 'short' : (ciTotal != null && ciTotal < targetMonths) ? 'marginal' : 'meets';
|
|
999
|
+
if (verdict === 'meets' && sl.remainingMonths > 60) return null;
|
|
1000
|
+
return { verdict, level: verdict === 'short' ? 'high' : verdict === 'marginal' ? 'medium' : 'info', pointTotal, ciTotal };
|
|
1001
|
+
}
|
|
1002
|
+
if (sl.remainingMonths > 60) return null;
|
|
1003
|
+
return { verdict: null, level: sl.remainingMonths < 3 ? 'high' : sl.remainingMonths < 12 ? 'medium' : 'info', pointTotal, ciTotal };
|
|
813
1004
|
}
|
|
814
1005
|
|
|
815
1006
|
const get_stability = {
|
|
@@ -855,7 +1046,7 @@ const get_stability = {
|
|
|
855
1046
|
}
|
|
856
1047
|
if (!Number.isFinite(value)) return;
|
|
857
1048
|
if (m.unit && !unit) unit = m.unit;
|
|
858
|
-
const ac = m.
|
|
1049
|
+
const ac = _resolveSpec(m, t.templateId).spec;
|
|
859
1050
|
if (ac) { if (Number.isFinite(ac.min)) specMin = specMin == null ? ac.min : Math.max(specMin, ac.min); if (Number.isFinite(ac.max)) specMax = specMax == null ? ac.max : Math.min(specMax, ac.max); }
|
|
860
1051
|
const cond = (m.conditions && Object.keys(m.conditions).length)
|
|
861
1052
|
? Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(' · ') : '';
|
|
@@ -871,6 +1062,14 @@ const get_stability = {
|
|
|
871
1062
|
if (!points.length) return { error: `No dated "${args.parameter}" measurements on ${entity.uid || entity.id}.` };
|
|
872
1063
|
|
|
873
1064
|
const spec = { min: specMin, max: specMax };
|
|
1065
|
+
// Target shelf life: the formula's own Shelf life, else the workspace
|
|
1066
|
+
// default when this store carries it (the app's Settings → Stability).
|
|
1067
|
+
const formula = entityType === 'formulation' ? entity
|
|
1068
|
+
: (db.formulations || []).find(f => f.id === entity.formulationId) || null;
|
|
1069
|
+
const ownT = formula ? parseFloat(formula.shelfLifeMonths) : NaN;
|
|
1070
|
+
const defT = parseFloat(db.defaultShelfLifeMonths);
|
|
1071
|
+
const target = (Number.isFinite(ownT) && ownT > 0) ? { months: ownT, source: 'formula' }
|
|
1072
|
+
: (Number.isFinite(defT) && defT > 0) ? { months: defT, source: 'workspace default' } : null;
|
|
874
1073
|
const analyze = (pts) => {
|
|
875
1074
|
const ys = pts.map(p => p.value);
|
|
876
1075
|
const n = ys.length;
|
|
@@ -886,11 +1085,17 @@ const get_stability = {
|
|
|
886
1085
|
if (ic) out.controlChart = { mean: _round(ic.mean), ucl: _round(ic.ucl), lcl: _round(ic.lcl), sigma: _round(ic.sigma), outOfControlPoints: ic.ooc };
|
|
887
1086
|
if (spec.min != null || spec.max != null) out.spec = { ...spec, latestInSpec: (spec.min == null || ys[n - 1] >= spec.min) && (spec.max == null || ys[n - 1] <= spec.max) };
|
|
888
1087
|
const sl = _shelfLife(pts, spec);
|
|
1088
|
+
const as = _shelfLifeAssess(sl, target && target.months);
|
|
889
1089
|
if (sl) out.shelfLife = {
|
|
890
1090
|
headingToward: `${sl.edge} spec (${_round(sl.limit)})`,
|
|
891
1091
|
remainingMonths: _round(sl.remainingMonths, 1),
|
|
892
1092
|
ci95Months: sl.ciRemainingMonths != null ? _round(sl.ciRemainingMonths, 1) : null,
|
|
1093
|
+
fromT0Months: _round(sl.elapsedMonths + sl.remainingMonths, 1),
|
|
1094
|
+
ci95FromT0Months: sl.ciRemainingMonths != null ? _round(sl.elapsedMonths + sl.ciRemainingMonths, 1) : null,
|
|
893
1095
|
r2: _round(sl.r2, 2), basis: 'linear extrapolation to spec crossing',
|
|
1096
|
+
verdict: as ? as.verdict : null,
|
|
1097
|
+
concern: as ? as.level : null,
|
|
1098
|
+
reportable: !!as,
|
|
894
1099
|
};
|
|
895
1100
|
return out;
|
|
896
1101
|
};
|
|
@@ -908,12 +1113,13 @@ const get_stability = {
|
|
|
908
1113
|
return {
|
|
909
1114
|
entity: { type: entityType, uid: entity.uid, name: entity.name || entity.uid },
|
|
910
1115
|
parameter: args.parameter, unit, spec, pointCount: points.length, byCondition: split, series,
|
|
911
|
-
|
|
1116
|
+
targetShelfLife: target ? { months: target.months, source: target.source } : null,
|
|
1117
|
+
note: 'shelfLife.fromT0Months / ci95FromT0Months are the projected shelf life counted from the first test (T0): point estimate and the conservative (ICH Q1E) 95%-bound crossing. remainingMonths / ci95Months are the same, counted from the last test. verdict compares against targetShelfLife: "short" = the trend crosses spec before the target, "marginal" = the trend clears it but the 95% bound does not, "meets" = both clear it; null = no target set (then concern is by time left: high <3 mo, medium <12 mo). reportable=false means the app shows no projection (R² < 0.3, crossing already past, or beyond 5 years). Numbers are computed here — narrate them, do not recompute.',
|
|
912
1118
|
};
|
|
913
1119
|
},
|
|
914
1120
|
};
|
|
915
1121
|
|
|
916
|
-
export { _evalPassFail };
|
|
1122
|
+
export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess };
|
|
917
1123
|
export const tools = {
|
|
918
1124
|
list_test_results,
|
|
919
1125
|
get_test_result,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// =====================================================================
|
|
2
|
+
// DOCUMENTS — list_documents. Mirrors the app's Documents page
|
|
3
|
+
// (js/views/documents-library.js): one register of every uploaded file —
|
|
4
|
+
// ingredient SDS / PDS / CoA / other, test-result attachments, notebook
|
|
5
|
+
// attachments, and per-record uploads + links (record.fileAttachments[] on
|
|
6
|
+
// formulas, batches, samples, suppliers, equipment…). Metadata only: the
|
|
7
|
+
// file bytes are never read, so this cannot search the text inside a PDF.
|
|
8
|
+
// The summary block carries the same counts as the page's quick-filter tiles.
|
|
9
|
+
// =====================================================================
|
|
10
|
+
import { getStore } from '../data.js';
|
|
11
|
+
import { _lotCoaInfo } from './ingredients.js';
|
|
12
|
+
|
|
13
|
+
const DAY = 86400000;
|
|
14
|
+
const RECENT_DAYS = 7;
|
|
15
|
+
const SOON_DAYS = 30;
|
|
16
|
+
const live = (x) => x && !x._trashed && !x.deletedAt;
|
|
17
|
+
const norm = (s) => String(s == null ? '' : s).trim().toLowerCase();
|
|
18
|
+
|
|
19
|
+
// record.fileAttachments[] live on these collections (app: _ATTACH_ENTITY_COLL).
|
|
20
|
+
const ATTACH_COLL = {
|
|
21
|
+
formulation: 'formulations', ingredient: 'ingredients', batch: 'batches', sample: 'samples',
|
|
22
|
+
test: 'testResults', project: 'projects', template: 'templates', procedureTemplate: 'procedureTemplates',
|
|
23
|
+
parameter: 'parameters', equipment: 'equipment', supplier: 'suppliers',
|
|
24
|
+
};
|
|
25
|
+
const NOTE_COLL = {
|
|
26
|
+
ingredient: 'ingredients', formulation: 'formulations', batch: 'batches', sample: 'samples', project: 'projects',
|
|
27
|
+
test: 'testResults', testResult: 'testResults', template: 'templates', parameter: 'parameters', equipment: 'equipment',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// App: docExpiryStatus — date-only, local midnight; <0 expired, ≤30 soon.
|
|
31
|
+
function expiryInfo(doc, today) {
|
|
32
|
+
if (!doc || !doc.expiryDate) return { status: 'none', daysToExpiry: null };
|
|
33
|
+
const exp = new Date(String(doc.expiryDate).slice(0, 10) + 'T00:00:00');
|
|
34
|
+
if (isNaN(exp.getTime())) return { status: 'none', daysToExpiry: null };
|
|
35
|
+
const days = Math.round((exp - today) / DAY);
|
|
36
|
+
return { status: days < 0 ? 'expired' : days <= SOON_DAYS ? 'soon' : 'current', daysToExpiry: days };
|
|
37
|
+
}
|
|
38
|
+
function formatOf(name, mime, isLink) {
|
|
39
|
+
if (isLink) return 'link';
|
|
40
|
+
const m = norm(mime);
|
|
41
|
+
const ext = (String(name || '').split('.').pop() || '').toLowerCase();
|
|
42
|
+
if (m.startsWith('image/') || /^(jpe?g|png|gif|webp|bmp|svg|heic|tiff?)$/.test(ext)) return 'image';
|
|
43
|
+
if (m === 'application/pdf' || ext === 'pdf') return 'pdf';
|
|
44
|
+
return 'other';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function allDocuments(db) {
|
|
48
|
+
const out = [];
|
|
49
|
+
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
50
|
+
const push = (row) => {
|
|
51
|
+
const up = row.uploadedAt ? Date.parse(row.uploadedAt) : NaN;
|
|
52
|
+
out.push({ ...row, ageDays: isFinite(up) ? Math.floor((Date.now() - up) / DAY) : null });
|
|
53
|
+
};
|
|
54
|
+
(db.ingredients || []).filter(live).forEach(ing => (ing.documents || []).forEach(d => {
|
|
55
|
+
if (!d) return;
|
|
56
|
+
const ex = expiryInfo(d, today);
|
|
57
|
+
push({
|
|
58
|
+
type: String(d.docType || 'OTHER').toUpperCase(), name: d.filename || '', format: formatOf(d.filename, d.type, false),
|
|
59
|
+
record: 'ingredient', linked: ing.name || ing.uid || '', linkedId: ing.uid || ing.id,
|
|
60
|
+
lot: d.lotNumber || '', version: d.version != null ? String(d.version) : '', source: d.source || '',
|
|
61
|
+
expiryDate: d.expiryDate || null, expiryStatus: ex.status, daysToExpiry: ex.daysToExpiry,
|
|
62
|
+
sizeKb: Number.isFinite(+d.size) && d.size != null ? Math.round(+d.size / 102.4) / 10 : null,
|
|
63
|
+
uploadedAt: d.uploadedAt || null,
|
|
64
|
+
});
|
|
65
|
+
}));
|
|
66
|
+
const testsById = new Map((db.testResults || []).map(t => [t.id, t]));
|
|
67
|
+
(db.attachments || []).filter(live).forEach(a => {
|
|
68
|
+
const t = testsById.get(a.testResultId);
|
|
69
|
+
push({
|
|
70
|
+
type: 'TEST', name: a.fileName || '', format: formatOf(a.fileName, a.fileType || a.mime, false),
|
|
71
|
+
record: 'test', linked: (t ? (t.uid || t.id) : 'Test') + (a.measurementParameter ? ' · ' + a.measurementParameter : ''), linkedId: t ? (t.uid || t.id) : null,
|
|
72
|
+
lot: '', version: '', source: '', expiryDate: null, expiryStatus: 'none', daysToExpiry: null,
|
|
73
|
+
sizeKb: Number.isFinite(+a.fileSize) && a.fileSize != null ? Math.round(+a.fileSize / 102.4) / 10 : null,
|
|
74
|
+
uploadedAt: a.uploadedAt || null,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
(db.notebookEntries || []).filter(live).forEach(ne => (ne.attachments || []).forEach(att => {
|
|
78
|
+
if (!att) return;
|
|
79
|
+
const coll = NOTE_COLL[ne.entityType];
|
|
80
|
+
const rec = coll ? (db[coll] || []).find(x => x && x.id === ne.entityId) : null;
|
|
81
|
+
push({
|
|
82
|
+
type: 'NOTE', name: att.filename || '', format: formatOf(att.filename, att.type, false),
|
|
83
|
+
record: 'notebook', linked: rec ? (rec.name || rec.uid || '') : (ne.entityType || 'note'), linkedId: rec ? (rec.uid || rec.id) : null,
|
|
84
|
+
lot: '', version: '', source: '', expiryDate: null, expiryStatus: 'none', daysToExpiry: null,
|
|
85
|
+
sizeKb: Number.isFinite(+att.size) && att.size != null ? Math.round(+att.size / 102.4) / 10 : null,
|
|
86
|
+
uploadedAt: att.uploadedAt || null,
|
|
87
|
+
});
|
|
88
|
+
}));
|
|
89
|
+
Object.entries(ATTACH_COLL).forEach(([etype, coll]) => (db[coll] || []).filter(live).forEach(rec => (rec.fileAttachments || []).forEach(a => {
|
|
90
|
+
if (!a) return;
|
|
91
|
+
const isLink = a.kind === 'link';
|
|
92
|
+
push({
|
|
93
|
+
type: isLink ? 'LINK' : 'FILE', name: a.name || '', format: formatOf(a.name, a.mime, isLink),
|
|
94
|
+
record: etype, linked: rec.name || rec.uid || '', linkedId: rec.uid || rec.id,
|
|
95
|
+
...(isLink && a.url ? { url: a.url } : {}),
|
|
96
|
+
lot: '', version: '', source: '', expiryDate: null, expiryStatus: 'none', daysToExpiry: null,
|
|
97
|
+
sizeKb: (!isLink && Number.isFinite(+a.size) && a.size != null) ? Math.round(+a.size / 102.4) / 10 : null,
|
|
98
|
+
uploadedAt: a.createdAt || null,
|
|
99
|
+
});
|
|
100
|
+
})));
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function summary(db, docs) {
|
|
105
|
+
const ings = (db.ingredients || []).filter(live);
|
|
106
|
+
const missingSds = ings.filter(i => !(i.documents || []).some(d => d && d.docType === 'SDS'));
|
|
107
|
+
const ingById = new Map(ings.map(i => [i.id, i]));
|
|
108
|
+
const lotsWithoutCoa = (db.lots || []).filter(l => live(l) && _lotCoaInfo(l, ingById.get(l.ingredientId)).coaMissing);
|
|
109
|
+
return {
|
|
110
|
+
files: docs.length,
|
|
111
|
+
expired: docs.filter(d => d.expiryStatus === 'expired').length,
|
|
112
|
+
expiringIn30Days: docs.filter(d => d.expiryStatus === 'soon').length,
|
|
113
|
+
sdsOrCoaWithoutExpiry: docs.filter(d => d.record === 'ingredient' && d.expiryStatus === 'none' && (d.type === 'SDS' || d.type === 'COA')).length,
|
|
114
|
+
ingredientsMissingSds: missingSds.length,
|
|
115
|
+
sdsCoveragePct: ings.length ? Math.round(((ings.length - missingSds.length) / ings.length) * 100) : 100,
|
|
116
|
+
lotsInStockWithoutCoa: lotsWithoutCoa.length,
|
|
117
|
+
addedLast7Days: docs.filter(d => d.ageDays != null && d.ageDays <= RECENT_DAYS).length,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const list_documents = {
|
|
122
|
+
definition: {
|
|
123
|
+
name: 'list_documents',
|
|
124
|
+
description: 'The workspace document register — same list as the app\'s Documents page: ingredient SDS / PDS / CoA / other files (with lot, version, source, expiry), test-result and notebook attachments, and per-record uploads and links (formulas, batches, samples, suppliers, equipment…). Returns file METADATA only (never the text inside a file). Filters combine. The summary block matches the page\'s tiles: expired, expiring in 30 days, SDS/CoA without an expiry date, ingredients missing an SDS (+ coverage %), lots in stock without a CoA, files added in the last 7 days. For the lots themselves use list_lots with missing_coa: true.',
|
|
125
|
+
inputSchema: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
type: { type: 'array', items: { type: 'string' }, description: 'Document types to include: SDS, PDS, COA, OTHER (ingredient documents), TEST, NOTE, FILE (upload), LINK.' },
|
|
129
|
+
record: { type: 'string', description: 'Kind of record the file is attached to: ingredient, formulation, batch, sample, test, notebook, project, supplier, equipment, template, procedureTemplate, parameter.' },
|
|
130
|
+
linked: { type: 'string', description: 'Case-insensitive substring of the linked record\'s name / UID.' },
|
|
131
|
+
query: { type: 'string', description: 'Case-insensitive substring over file name, linked record, lot and source.' },
|
|
132
|
+
lot: { type: 'string', description: 'Exact lot number (case-insensitive).' },
|
|
133
|
+
format: { type: 'string', enum: ['pdf', 'image', 'link', 'other'], description: 'File format.' },
|
|
134
|
+
expiry: { type: 'string', enum: ['expired', 'soon', 'current', 'none'], description: 'Expiry status (ingredient documents only; soon = within 30 days).' },
|
|
135
|
+
expiring_within_days: { type: 'number', description: 'Only documents whose expiry date is within this many days (already expired included).' },
|
|
136
|
+
without_expiry: { type: 'boolean', description: 'true = only SDS / CoA files with no expiry date (the "SDS / CoA without expiry" tile).' },
|
|
137
|
+
uploaded_within_days: { type: 'number', description: 'Only files uploaded within this many days.' },
|
|
138
|
+
limit: { type: 'number', description: 'Max rows (default 200, max 1000).' },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
handler: async (args) => {
|
|
143
|
+
const { db } = getStore();
|
|
144
|
+
const all = allDocuments(db);
|
|
145
|
+
const limit = Math.min(1000, Math.max(1, args.limit || 200));
|
|
146
|
+
const types = Array.isArray(args.type) ? new Set(args.type.map(t => String(t).toUpperCase())) : (args.type ? new Set([String(args.type).toUpperCase()]) : null);
|
|
147
|
+
const q = norm(args.query), linkedQ = norm(args.linked);
|
|
148
|
+
const rows = all.filter(d => {
|
|
149
|
+
if (types && !types.has(d.type)) return false;
|
|
150
|
+
if (args.record && norm(d.record) !== norm(args.record)) return false;
|
|
151
|
+
if (linkedQ && !(norm(d.linked).includes(linkedQ) || norm(d.linkedId).includes(linkedQ))) return false;
|
|
152
|
+
if (q && ![d.name, d.linked, d.lot, d.source].some(v => norm(v).includes(q))) return false;
|
|
153
|
+
if (args.lot && norm(d.lot) !== norm(args.lot)) return false;
|
|
154
|
+
if (args.format && d.format !== args.format) return false;
|
|
155
|
+
if (args.expiry && d.expiryStatus !== args.expiry) return false;
|
|
156
|
+
if (args.expiring_within_days != null && !(d.daysToExpiry != null && d.daysToExpiry <= args.expiring_within_days)) return false;
|
|
157
|
+
if (args.without_expiry === true && !(d.record === 'ingredient' && d.expiryStatus === 'none' && (d.type === 'SDS' || d.type === 'COA'))) return false;
|
|
158
|
+
if (args.uploaded_within_days != null && !(d.ageDays != null && d.ageDays <= args.uploaded_within_days)) return false;
|
|
159
|
+
return true;
|
|
160
|
+
}).sort((a, b) => String(b.uploadedAt || '').localeCompare(String(a.uploadedAt || '')));
|
|
161
|
+
return { summary: summary(db, all), totalMatching: rows.length, returned: Math.min(rows.length, limit), documents: rows.slice(0, limit) };
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export const tools = { list_documents };
|
package/tools/formulations.js
CHANGED
|
@@ -22,6 +22,8 @@ function _trimFormulation(f) {
|
|
|
22
22
|
application: f.application || '',
|
|
23
23
|
owner: f.owner || '',
|
|
24
24
|
regulatoryCategory: f.regulatoryCategory || '',
|
|
25
|
+
// Target shelf life (months): sets finished-goods expiry AND is what
|
|
26
|
+
// get_stability judges the projected shelf life against.
|
|
25
27
|
shelfLifeMonths: f.shelfLifeMonths ?? null,
|
|
26
28
|
// Density of the MIXED product (g/mL), when declared — volume-targeted
|
|
27
29
|
// batches scale by it instead of the ideal-mix estimate. null = not set.
|
package/tools/ingredients.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// ============================================================
|
|
6
6
|
|
|
7
7
|
import { getStore, resolveById } from '../data.js';
|
|
8
|
+
import { ingredientSmiles } from '../peptide.js';
|
|
8
9
|
|
|
9
10
|
// FormLab's export is the raw db, so ingredients carry the APP field shapes:
|
|
10
11
|
// `cas` (not casNumber) and `cost = {value, currency, unit}` (not a flat
|
|
@@ -86,6 +87,22 @@ function _trimIngredient(i) {
|
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
// Certificate of Analysis for a lot — mirrors the app's lotsMissingCoa
|
|
91
|
+
// (js/inventory-lots.js): covered when lot.coaDocId points at a COA document
|
|
92
|
+
// still on the ingredient, or (unlinked) when a COA on the ingredient carries
|
|
93
|
+
// the same lot number. coaMissing flags an IN-STOCK (remaining > 0), non-
|
|
94
|
+
// rejected lot with neither — the Documents page "Lots in stock without CoA".
|
|
95
|
+
function _lotCoaInfo(l, ing) {
|
|
96
|
+
const norm = v => String(v == null ? '' : v).trim().toLowerCase();
|
|
97
|
+
const coas = ((ing && ing.documents) || []).filter(d => d && d.docType === 'COA');
|
|
98
|
+
let doc = l.coaDocId ? coas.find(d => d.id === l.coaDocId) : null;
|
|
99
|
+
let how = doc ? 'linked' : null;
|
|
100
|
+
if (!doc && norm(l.lotNumber)) { doc = coas.find(d => norm(d.lotNumber) === norm(l.lotNumber)) || null; if (doc) how = 'lot-number match'; }
|
|
101
|
+
const rem = Number(l.qtyRemaining);
|
|
102
|
+
const inStock = isFinite(rem) && rem > 0 && l.status !== 'rejected';
|
|
103
|
+
return { coa: doc ? { file: doc.filename || '', match: how, expiryDate: doc.expiryDate || null } : null, coaMissing: inStock && !doc };
|
|
104
|
+
}
|
|
105
|
+
|
|
89
106
|
// Resolve the approved source a lot was received from (lot.sourceId → the
|
|
90
107
|
// ingredient's ing.sources[]). Null when the lot predates sources or is unlinked.
|
|
91
108
|
function _lotSourceInfo(l) {
|
|
@@ -200,6 +217,7 @@ const get_ingredient = {
|
|
|
200
217
|
receivedDate: l.receivedDate || null, expiryDate: l.expiryDate || null,
|
|
201
218
|
supplier: l.supplier || '', supplierId: l.supplierId || null, status: l.status || 'active',
|
|
202
219
|
sourceId: l.sourceId || null, source: _lotSourceInfo(l),
|
|
220
|
+
..._lotCoaInfo(l, ing),
|
|
203
221
|
}));
|
|
204
222
|
return {
|
|
205
223
|
..._trimIngredient(ing),
|
|
@@ -249,7 +267,7 @@ async function _loadRDKitNode() {
|
|
|
249
267
|
const find_by_smarts = {
|
|
250
268
|
definition: {
|
|
251
269
|
name: 'find_by_smarts',
|
|
252
|
-
description: 'Find every ingredient whose SMILES contains the given SMARTS substructure pattern. Requires RDKit-JS (optional dependency); install with `npm install @rdkit/rdkit` in the mcp folder if you get a "not installed" error.',
|
|
270
|
+
description: 'Find every ingredient whose SMILES contains the given SMARTS substructure pattern. Peptides with only a sequence are searched with the structure derived from it (as the app draws them); those rows carry smilesDerived: true. Requires RDKit-JS (optional dependency); install with `npm install @rdkit/rdkit` in the mcp folder if you get a "not installed" error.',
|
|
253
271
|
inputSchema: {
|
|
254
272
|
type: 'object',
|
|
255
273
|
properties: {
|
|
@@ -272,7 +290,8 @@ const find_by_smarts = {
|
|
|
272
290
|
underlying: e?.message || String(e),
|
|
273
291
|
};
|
|
274
292
|
}
|
|
275
|
-
const candidates = (db.ingredients || []).filter(i => i && !i._trashed
|
|
293
|
+
const candidates = (db.ingredients || []).filter(i => i && !i._trashed)
|
|
294
|
+
.map(i => ({ ing: i, ...ingredientSmiles(i) })).filter(c => c.smiles);
|
|
276
295
|
const matches = [];
|
|
277
296
|
let invalid = 0;
|
|
278
297
|
const limit = Math.min(1000, Math.max(1, args.limit || 100));
|
|
@@ -280,8 +299,8 @@ const find_by_smarts = {
|
|
|
280
299
|
const query = rdkit.get_qmol(smarts);
|
|
281
300
|
if (!query) return { error: `Invalid SMARTS: "${smarts}"` };
|
|
282
301
|
try {
|
|
283
|
-
for (const ing of candidates) {
|
|
284
|
-
const mol = rdkit.get_mol(
|
|
302
|
+
for (const { ing, smiles, derived } of candidates) {
|
|
303
|
+
const mol = rdkit.get_mol(smiles);
|
|
285
304
|
if (!mol) { invalid++; continue; }
|
|
286
305
|
try {
|
|
287
306
|
const raw = mol.get_substruct_match(query);
|
|
@@ -289,7 +308,7 @@ const find_by_smarts = {
|
|
|
289
308
|
try {
|
|
290
309
|
const parsed = JSON.parse(raw);
|
|
291
310
|
if (Array.isArray(parsed.atoms) && parsed.atoms.length > 0) {
|
|
292
|
-
matches.push(_trimIngredient(ing));
|
|
311
|
+
matches.push(derived ? { ..._trimIngredient(ing), smilesDerived: true } : _trimIngredient(ing));
|
|
293
312
|
if (matches.length >= limit) break;
|
|
294
313
|
}
|
|
295
314
|
} catch { /* skip parse fail */ }
|
|
@@ -326,6 +345,7 @@ const list_lots = {
|
|
|
326
345
|
ingredient_id: { type: 'string', description: 'Only lots for this ingredient (internal id or UID, e.g. ING-001).' },
|
|
327
346
|
expiring_within_days: { type: 'number', description: 'Only lots whose expiryDate is within this many days from now (negative days = already expired are always included).' },
|
|
328
347
|
status: { type: 'string', description: 'Filter by lot status: "active", "hold", "quarantine", or "rejected". Held/quarantined/rejected lots are blocked from auto-consumption (use "hold" to find lots put on hold for a QC / recall).' },
|
|
348
|
+
missing_coa: { type: 'boolean', description: 'true = only lots still in stock (not rejected) that have no Certificate of Analysis — neither linked nor a CoA on the ingredient with the same lot number. Same list as the app\'s Documents → "Lots in stock without CoA".' },
|
|
329
349
|
limit: { type: 'number', description: 'Max rows (default 200, max 1000).' },
|
|
330
350
|
},
|
|
331
351
|
},
|
|
@@ -339,10 +359,12 @@ const list_lots = {
|
|
|
339
359
|
if (!ing) return { error: `No ingredient found for id "${args.ingredient_id}".` };
|
|
340
360
|
}
|
|
341
361
|
const nameById = new Map((db.ingredients || []).map(i => [i.id, i.name]));
|
|
362
|
+
const ingById = new Map((db.ingredients || []).map(i => [i.id, i]));
|
|
342
363
|
const nowMs = Date.now();
|
|
343
364
|
const rows = (db.lots || []).filter(l => {
|
|
344
365
|
if (!l || l._trashed) return false;
|
|
345
366
|
if (ing && l.ingredientId !== ing.id) return false;
|
|
367
|
+
if (args.missing_coa === true && !_lotCoaInfo(l, ingById.get(l.ingredientId)).coaMissing) return false;
|
|
346
368
|
if (args.status && String(l.status || 'active').toLowerCase() !== String(args.status).toLowerCase()) return false;
|
|
347
369
|
if (args.expiring_within_days != null) {
|
|
348
370
|
if (!l.expiryDate) return false;
|
|
@@ -365,6 +387,7 @@ const list_lots = {
|
|
|
365
387
|
holdReason: l.holdReason || '',
|
|
366
388
|
unitCost: (l.unitCost != null && l.unitCost !== '') ? l.unitCost : null,
|
|
367
389
|
unitCostCcy: l.unitCostCcy || null,
|
|
390
|
+
..._lotCoaInfo(l, ingById.get(l.ingredientId)),
|
|
368
391
|
}));
|
|
369
392
|
return { totalMatching: rows.length, returned: Math.min(rows.length, limit), lots: rows.slice(0, limit) };
|
|
370
393
|
},
|
|
@@ -450,4 +473,4 @@ const list_inventory = {
|
|
|
450
473
|
export const tools = { list_ingredients, get_ingredient, find_by_smarts, list_lots, list_inventory };
|
|
451
474
|
|
|
452
475
|
// Exported for the batch-analyze tools (avg cost metric) to reuse the same cost normalization.
|
|
453
|
-
export { _costPerKg, _densityKgPerL };
|
|
476
|
+
export { _costPerKg, _densityKgPerL, _lotCoaInfo };
|
package/tools/library.js
CHANGED
|
@@ -48,7 +48,7 @@ function _usageRollup(db, tests, measFilter) {
|
|
|
48
48
|
let tf = 0;
|
|
49
49
|
(t.measurements || []).filter(m => !measFilter || measFilter(m, t)).forEach(m => {
|
|
50
50
|
readings++;
|
|
51
|
-
const pf = _evalPassFail(m); if (pf !== 'no-criteria' && pf !== 'untested') withSpec++; if (pf === 'fail') { fails++; tf++; }
|
|
51
|
+
const pf = _evalPassFail(m, t.templateId); if (pf !== 'no-criteria' && pf !== 'untested') withSpec++; if (pf === 'fail') { fails++; tf++; }
|
|
52
52
|
});
|
|
53
53
|
const key = f ? f.id : '(no formula)';
|
|
54
54
|
if (!byF.has(key)) byF.set(key, { formula: f ? (f.uid || f.name) : null, formulaName: f ? f.name : null, reports: 0, lastRun: '', fails: 0 });
|
|
@@ -168,7 +168,7 @@ const get_equipment = {
|
|
|
168
168
|
const pn = (m.parameter || '').trim(); if (!pn) return;
|
|
169
169
|
const v = _num(m); const isMine = mine(m, t); const bucket = isMine ? here : fleet;
|
|
170
170
|
if (!bucket.has(pn)) bucket.set(pn, { parameter: pn, readings: 0, vals: [], fails: 0 });
|
|
171
|
-
const g = bucket.get(pn); g.readings++; if (v != null) g.vals.push(v); if (isMine && _evalPassFail(m) === 'fail') g.fails++;
|
|
171
|
+
const g = bucket.get(pn); g.readings++; if (v != null) g.vals.push(v); if (isMine && _evalPassFail(m, t.templateId) === 'fail') g.fails++;
|
|
172
172
|
}));
|
|
173
173
|
const measured = [...here.values()].map(g => {
|
|
174
174
|
const f = fleet.get(g.parameter); const mh = _mean(g.vals), me = f ? _mean(f.vals) : null;
|
|
@@ -283,7 +283,7 @@ const get_test_method = {
|
|
|
283
283
|
const v = _num(m); if (v != null) vals.push(v);
|
|
284
284
|
const inst = _resolveInstrument(m, r, db); const key = inst.name || inst.equipmentId || '(unresolved)';
|
|
285
285
|
if (!byInst.has(key)) byInst.set(key, { instrument: key, readings: 0, vals: [], fails: 0 });
|
|
286
|
-
const g = byInst.get(key); g.readings++; if (v != null) g.vals.push(v); if (_evalPassFail(m) === 'fail') g.fails++;
|
|
286
|
+
const g = byInst.get(key); g.readings++; if (v != null) g.vals.push(v); if (_evalPassFail(m, r.templateId) === 'fail') g.fails++;
|
|
287
287
|
}));
|
|
288
288
|
const byInstrument = [...byInst.values()].map(g => ({ instrument: g.instrument, readings: g.readings, mean: _r(_mean(g.vals)), fails: g.fails })).sort((a, b) => b.readings - a.readings);
|
|
289
289
|
const valueSummary = { readings: vals.length, mean: _r(_mean(vals)), sd: _r(_sd(vals)), min: vals.length ? Math.min(...vals) : null, max: vals.length ? Math.max(...vals) : null, unit: p.defaultUnit || '' };
|
|
@@ -454,7 +454,7 @@ const get_test_panel = {
|
|
|
454
454
|
tests.forEach(r => (r.measurements || []).forEach(m => {
|
|
455
455
|
const hit = (pid && m.parameterId === pid) || ((!m.parameterId || !pid) && _norm(m.parameter) === nm);
|
|
456
456
|
if (!hit) return; n++;
|
|
457
|
-
const pf = _evalPassFail(m); if (pf !== 'no-criteria' && pf !== 'untested') withSpec++; if (pf === 'fail') fails++;
|
|
457
|
+
const pf = _evalPassFail(m, r.templateId); if (pf !== 'no-criteria' && pf !== 'untested') withSpec++; if (pf === 'fail') fails++;
|
|
458
458
|
}));
|
|
459
459
|
return { parameter: row.parameter || row.name, readings: n, fails, failRatePct: withSpec ? _r(100 * fails / withSpec, 0) : null };
|
|
460
460
|
});
|