DEV Community

kent-tokyo
kent-tokyo

Posted on

Building sdsforge: A Library for Generating SDS Drafts and Converting Documents to JSON

An SDS (Safety Data Sheet) is the document a business handling chemical products puts together to communicate a product's composition, hazards, and handling instructions. In Japan, the Ministry of Health, Labour and Welfare (MHLW) defines a standard JSON format for exchanging this data, and sdsforge is a library built around that format — a Python-first, Rust-powered toolkit with three main commands. assist has an LLM propose candidate values from a supplier's SDS; generate assembles a draft deterministically from a formulation sheet; to-json (I'll call it convert here) converts an existing SDS document into that standard JSON. All three are built on the same stance: don't unconditionally trust the output of an automated process.

assist — pulling candidates from a supplier's SDS

This one only targets Section 4, First-Aid Measures. Feed it a single supplier SDS document (PDF/DOCX/XLSX/etc.) and it writes candidate values out to their own JSON file, on the assumption a human reviews them before anything else happens. It never writes to official_sds.json or ProductInput.

sdsforge assist --source supplier_sds.pdf --source-kind supplier-sds --section 4 --output proposals.json
Enter fullscreen mode Exit fullscreen mode

For each candidate, the model returns a proposed value along with source_excerpt — the quote it claims the value came from. sdsforge checks whether that excerpt actually appears, verbatim, in the text it extracted from the source, and drops any candidate where it doesn't. source_page is always null, because the text-extraction output has no page boundaries to check the model's self-reported page number against. Confidence is capped at medium; there's no path that upgrades it further.

{
  "source_evidence_level": "supplier_sds",
  "extraction_method": "llm_extraction",
  "proposals": [
    {
      "path": "FirstAidMeasures.ExposureRoute.FirstAidSkin.FullText",
      "proposed_value": "Wash off with soap and plenty of water. Consult a physician.",
      "source_page": null,
      "source_excerpt": "In case of skin contact\nWash off with soap and plenty of water. Consult a physician.",
      "confidence": "medium"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

generate — assembling a draft from a formulation

generate builds a JSON draft for an SDS that doesn't exist yet, starting from a formulation sheet — components, CAS numbers, concentrations. No LLM involved; it's deterministic, rule-based processing only.

trade_name: "AllClean Multi-Surface Cleaner"
components:
  - cas_number: "7732-18-5"
    name: "Water"
    concentration:
      exact: 85.0
      unit: "%"
  - cas_number: "151-21-3"
    name: "Sodium Lauryl Sulfate"
    concentration:
      lower: 5.0
      upper: 15.0
      unit: "%"
Enter fullscreen mode Exit fullscreen mode
sdsforge generate --input product.yaml --output-dir generated --enrich
Enter fullscreen mode Exit fullscreen mode

Running it against a different formulation — 90% water, 5–10% isopropyl alcohol, with a measured flash point attached — produces this in the corresponding field of official_sds.json:

{
  "PhysicalChemicalProperties": {
    "FlashPoint": [
      {
        "Condition": "20°C",
        "Method": "Closed Cup (ASTM D93)",
        "NumericRangeWithUnitAndQualifier": {
          "ExactValue": { "Value": 12.0 },
          "Unit": "degC"
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The input YAML also carried a lot number, sample_id: GOLDEN-LOT-0002, and a batch number, batch_id: GOLDEN-BATCH-0002, tied to that measurement — neither shows up anywhere in official_sds.json. Internal traceability data and the contents of a document meant for external submission are treated as separate things; the lot and batch numbers only survive in the provenance section of generation_report.json, where this flash point is recorded with source_type: "product_test_report" and confidence: "high". Properties other than flash point — boiling point, vapor pressure, explosive limits, self-reactivity, and so on — weren't given any measurement evidence, so no value gets produced for them; they're just listed under unresolved in generation_report.json. When a number isn't known, nothing plausible-looking gets computed or estimated to fill the gap.

Building this command kept raising the same judgment call: when an input value turns out to be wrong, whether to silently correct it so the draft comes out complete, or leave it alone and throw it back to a human. No code path corrects a value by inference.

Starting from the CAS check digit

The simplest case is the CAS number itself — the registry number that identifies a chemical substance. It carries a trailing check digit that's mechanically derivable from the digits before it, which means a typo is detectable, and in principle the correct value could be recomputed too. The test suite includes an input where water's CAS number, 7732-18-5, has its last digit changed to 7732-18-6.

sdsforge records that mismatch as a HIGH-level finding, GEN-CAS-CHECKDIGIT, and sets release_status to Blocked. review_report.md ends up reading:

## Release status

BLOCKED

## Blocking issues

- **[HIGH] GEN-CAS-CHECKDIGIT**: Water: CAS '7732-18-6' has an
  invalid check digit (expected 5).
Enter fullscreen mode Exit fullscreen mode

official_sds.json still outputs 7732-18-6, exactly as given in the input — there's no path that rewrites it back to 7732-18-5, because the tool has no way to rule out the possibility that the trailing digit is correct and some other field is the one that's actually wrong. The same number also gets checked by MHLW's general-purpose validator, validate_typed (the one --strict-mhlw uses), but that only produces a WARN and doesn't block a release on its own. What actually blocks it is generate's own check.

A CAS check digit is still a mechanical, string-level computation — easy to reason about. Things get harder once --enrich starts pulling structure and molecular formula from PubChem.

Once molecular structure enters the picture

For a given CAS number, PubChem returns the IUPAC name, molecular formula, and SMILES (a notation that represents a molecule's structure as a single-line string) as separate fields. Being separate fields, the formula and the SMILES can end up contradicting each other. chematic is what actually parses the SMILES, recomputes the molecular formula, charge, and fragment count from it, and cross-checks the result against PubChem's own value:

let mol = smiles::parse(source_smiles)?;
let has_multiple_fragments =
    chem::largest_fragment(&mol).atom_count() < mol.atom_count();
let calculated_formula = chem::calc_mol_formula(&mol);
let mismatch = !formulas_equivalent(&resolver_formula, &calculated_formula);
Enter fullscreen mode Exit fullscreen mode

largest_fragment reduces a molecule down to its largest connected fragment, but here it's only called to detect whether more than one fragment exists — the reduced result itself gets thrown away. Sodium chloride (CAS 7647-14-5) shows why that matters. Its SMILES is [Na+].[Cl-], two disconnected fragments: a sodium ion and a chloride ion. Apply the obvious-looking simplification — "just keep the larger fragment" — and the record turns into either sodium or chlorine alone, a different substance than the one the CAS number actually refers to.

{
  "MolecularFormula": "ClNa",
  "SMILES": "[Na+].[Cl-]",
  "SubstanceIdentifiers": {
    "SubstanceIdentity": { "CASno": { "FullText": ["7647-14-5"] } }
  }
}
Enter fullscreen mode Exit fullscreen mode

That's the actual value written to official_sds.json: fragmentation gets detected, and both fragments are kept as-is. generation_report.json's findings records it like this:

CAS '7647-14-5': structure has more than one disconnected fragment
(e.g. a salt or solvate) — reported for review, not automatically
rejected or reduced to its largest fragment.
Enter fullscreen mode Exit fullscreen mode

The verdict is ReviewRequired, and it doesn't block the release — being a salt isn't unusual on its own.

A formula mismatch is a level worse. The test suite has a case where CAS 64-17-5 (ethanol), whose SMILES is CCO, gets deliberately paired with C6H12O6 — glucose's molecular formula. (This isn't an inconsistency actually observed from PubChem; it's constructed to guarantee this code path gets exercised.) chematic computes C2H6O from CCO, which doesn't match:

CAS '64-17-5': resolver formula 'C6H12O6' does not match
chematic-calculated formula 'C2H6O' from the resolved structure
— not reconciled automatically.
Enter fullscreen mode Exit fullscreen mode

Here, rather than picking one of the two formula values, the field itself is left empty and the release is Blocked. Both values stay in generation_report.json's evidence, so a human decides in review which one is right. PAINS/Brenk screening — matching against known problematic structural patterns — follows the same logic: it's kept in its own screening_alerts field, separate from the release verdict, and doesn't block anything by itself.

The source comments state outright that chematic's standardize, uncharge, and neutralize_charges — functions that rewrite the structure itself — are never called. Even a canonicalized SMILES doesn't guarantee actual chemical identity; it's only a deterministic transformation for that particular version of chematic. Being able to recompute a value and being allowed to overwrite an existing one with that recomputed value are different questions.

About chematic

chematic is a cheminformatics library written in pure Rust, providing SMILES parsing, physical-property calculations (molecular weight, LogP, and so on), fingerprint-based similarity search, and 3D structure generation, with bindings for Rust, Python, and WebAssembly. Having no C library dependency, it builds with a plain cargo build, and the browser WASM bundle comes in at 719 KB. It's also usable standalone with pip install chematic.

sdsforge only touches a small slice of that surface. CAS resolution, structure validation, and SDS generation are split into separate layers, and chematic gets called only from the middle one:

enrichment  — resolve a CAS number against PubChem candidates
normalize   — parse and canonicalize the resolved candidate, detect inconsistencies (this is where chematic lives)
generation  — decide whether to adopt a finding, apply it to the draft
Enter fullscreen mode Exit fullscreen mode

The normalize layer itself does no network access and no CAS resolution. Behind a chematic-normalization feature flag, it calls only SMILES parsing, canonicalization, formula/charge recomputation, and PAINS/Brenk screening — none of chematic's other capabilities, like fingerprinting or 3D generation, get used here.

convert (to-json) — turning an existing SDS into JSON

This one converts an SDS document already in hand (PDF, DOCX, HTML, etc.) into the MHLW standard JSON. An LLM maps the free-form text onto roughly 200 schema fields, and that's followed by one deterministic, LLM-free check: whether each extracted value actually appears, verbatim, in the source text.

A Japanese product name makes the clearest example. If the source document contains zero kana characters but the extraction result comes back with a Japanese product name anyway, it warns:

[SourceVerify] Section 1 (TradeNameJP): '<extracted product name>' not found
verbatim in source text (source has no Japanese — likely a language
hallucination).
Enter fullscreen mode Exit fullscreen mode

CAS numbers get the same verbatim check, limited to ones that are already correctly formatted. But unlike assist's rejection-on-verification-failure or generate's HIGH-level check, a mismatch here doesn't stop the conversion. It's recorded as a warning in the report, and the JSON value is left as extracted. For a conversion that has to run over a large volume of real-world documents, outputting a suspicious value once and routing it to review is the more workable call.

Building all three commands kept surfacing the same distinction: whether a value can be fixed and whether it should be fixed are different questions. The one thing that doesn't move, only how hard it's enforced depending on the risk involved, is refusing to silently treat something unverifiable as correct.

https://github.com/kent-tokyo/sdsforge

Top comments (0)