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 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` | Pareto-style: parameters that fail acceptance most often |
49
- | `get_coverage_matrix` | Which formulations × parameters have been measured (TEST coverage) |
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 39 tools become callable in any conversation.
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 37 tools (see ./tools/) over stdio. The MCP host (Claude
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.24",
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.22",
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.22",
16
+ "version": "0.6.26",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -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: 'Pareto-style failure analysis: which test parameters fail their acceptance criteria most often, and on which samples / formulations. Returns ranked list of (parameter, failureCount, failureRate, exampleFailures).',
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
- // Per-parameter aggregator
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
- (db.testResults || []).forEach(t => {
409
- if (!t || t._trashed) return;
410
- if (args.since_date && (t.testDate || '') < args.since_date) return;
411
- (t.measurements || []).forEach(m => {
412
- if (!m || !m.parameter) return;
413
- if (args.parameter && !norm(m.parameter).includes(norm(args.parameter))) return;
414
- if (!byParam.has(m.parameter)) {
415
- byParam.set(m.parameter, { total: 0, failures: 0, examples: [] });
416
- }
417
- const agg = byParam.get(m.parameter);
418
- agg.total += 1;
419
- const pf = _evalPassFail(m);
420
- if (pf === 'fail') {
421
- agg.failures += 1;
422
- if (agg.examples.length < limitExamples) {
423
- const samp = indexes.samplesById.get(t.sampleId);
424
- const f = samp?.formulationId ? indexes.formulationsById.get(samp.formulationId) : null;
425
- agg.examples.push({
426
- testId: t.uid || t.id,
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
- // Minimal pass/fail evaluator — mirrors FormLab's _evalPassFail logic
460
- // for scalar numeric measurements with min/max/target acceptance.
461
- function _evalPassFail(m) {
462
- if (!m || m.value == null || m.value === '') return 'untested';
463
- const spec = m.acceptanceCriteria;
464
- if (!spec) return 'no-criteria';
465
- const v = parseFloat(m.value);
466
- if (!isFinite(v)) {
467
- // Non-numeric required value
468
- if (spec.requiredValue != null) {
469
- return String(m.value).trim() === String(spec.requiredValue).trim() ? 'pass' : 'fail';
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 'no-criteria';
608
+ return 'pass';
472
609
  }
473
- if (spec.target != null) return Math.abs(v - parseFloat(spec.target)) <= (spec.tolerance ?? 0) ? 'pass' : 'fail';
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 map: which formulations have been tested on which parameters. Returns counts per (formulation × parameter) cell so the LLM can answer "what\'s untested" or "what\'s been measured many times" questions.',
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 counts = new Map(); // formId -> Map(param -> count)
502
- (db.testResults || []).forEach(t => {
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
- const formId = samp?.formulationId;
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 { formulationCount: formRows.length, parameterCount: paramCols.length, parameters: paramCols, rows: formRows };
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 || '')) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v ?? '');
527
- const lines = [['Formulation', ...paramCols].map(esc).join(',')];
528
- formRows.forEach(r => lines.push([r.formulation, ...paramCols.map(p => r[p])].map(esc).join(',')));
529
- return `# Coverage Matrix · ${formRows.length} formulations × ${paramCols.length} parameters\n` + lines.join('\n');
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.acceptanceCriteria;
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
- note: 'shelfLife.remainingMonths is the point-estimate months from the last test to the spec crossing; ci95Months is the conservative (ICH-style) estimate where the 95% confidence bound crosses spec. Numbers are computed here — narrate them, do not recompute.',
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 };
@@ -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.
@@ -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 && i.smiles);
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(ing.smiles);
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
  });