Skip to main content

sdsforge_core/converter/
mod.rs

1pub mod compliance;
2pub mod corrector;
3pub mod extractor;
4pub mod generator;
5pub mod html;
6pub mod llm;
7pub mod pdf;
8pub mod template;
9pub mod validator;
10
11use std::path::Path;
12
13// ---------------------------------------------------------------------------
14// MHLW §3.3: prune fields with no valid value
15// ---------------------------------------------------------------------------
16
17/// Recursively remove null, empty-string, empty-array, and empty-object fields
18/// from a JSON value tree.
19///
20/// Per MHLW SDS data exchange format §3.3, fields with no valid value must be
21/// omitted entirely rather than serialised as `""` or `null`.
22pub fn prune_empty_fields(v: serde_json::Value) -> serde_json::Value {
23    use serde_json::Value;
24    match v {
25        Value::Object(map) => {
26            let pruned: serde_json::Map<_, _> = map
27                .into_iter()
28                .filter_map(|(k, v)| {
29                    let pv = prune_empty_fields(v);
30                    match &pv {
31                        Value::Null => None,
32                        Value::String(s) if s.trim().is_empty() => None,
33                        Value::Array(a)  if a.is_empty() => None,
34                        Value::Object(o) if o.is_empty() => None,
35                        _ => Some((k, pv)),
36                    }
37                })
38                .collect();
39            Value::Object(pruned)
40        }
41        Value::Array(arr) => {
42            let pruned: Vec<_> = arr
43                .into_iter()
44                .map(prune_empty_fields)
45                .filter(|v| match v {
46                    Value::Null => false,
47                    Value::String(s) if s.trim().is_empty() => false,
48                    Value::Array(a)  if a.is_empty() => false,
49                    Value::Object(o) if o.is_empty() => false,
50                    _ => true,
51                })
52                .collect();
53            Value::Array(pruned)
54        }
55        other => other,
56    }
57}
58
59use serde::{Deserialize, Serialize};
60
61use crate::country::SourceCountry;
62use crate::error::SdsError;
63use crate::language::Language;
64use crate::schema::SdsRoot;
65
66// ---------------------------------------------------------------------------
67// ConversionReport
68// ---------------------------------------------------------------------------
69
70/// Structured report produced alongside every `to-json` conversion.
71///
72/// Describes what was extracted, what was missing, and any normalisations
73/// applied to meet the JIS Z 7253 / MHLW standard.
74///
75/// The report is `serde`-serialisable so it can be written as JSON by the CLI
76/// and consumed as a structured value by web-app / API callers.
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct ConversionReport {
79    /// BCP-47 tag of the detected or user-specified source language
80    /// (e.g. `"ja"`, `"en"`, `"zh-CN"`, `"zh-TW"`).
81    pub source_language: String,
82    /// `true` if the language was inferred from text content rather than
83    /// supplied explicitly via `--lang` / `ConvertConfig::source_language`.
84    pub language_auto_detected: bool,
85    /// MHLW section keys that contain at least one extracted value.
86    pub populated_sections: Vec<String>,
87    /// MHLW section keys for which no data was found.
88    pub empty_sections: Vec<String>,
89    /// Notes explaining how source values were normalised to conform to the
90    /// current JIS Z 7253 / MHLW standard.
91    ///
92    /// Example: section heading terminology updated in JIS Z 7253:2019.
93    pub standardization_notes: Vec<String>,
94    /// Validation warnings emitted by the MHLW schema validator.
95    pub warnings: Vec<String>,
96    /// Country-specific compliance gap report, present when a source country is known.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub compliance_diff: Option<compliance::ComplianceDiffReport>,
99}
100
101impl ConversionReport {
102    /// Build a `ConversionReport` from a completed [`SdsRoot`] and metadata.
103    pub fn from_sds(
104        sds: &SdsRoot,
105        source_language: Language,
106        language_auto_detected: bool,
107        warnings: Vec<String>,
108    ) -> Self {
109        let mut populated = Vec::new();
110        let mut empty = Vec::new();
111
112        macro_rules! check_opt {
113            ($key:literal, $field:expr) => {
114                if $field.is_some() {
115                    populated.push($key.to_string());
116                } else {
117                    empty.push($key.to_string());
118                }
119            };
120        }
121        macro_rules! check_vec {
122            ($key:literal, $field:expr) => {
123                if $field.as_ref().map_or(false, |v: &Vec<_>| !v.is_empty()) {
124                    populated.push($key.to_string());
125                } else {
126                    empty.push($key.to_string());
127                }
128            };
129        }
130
131        check_opt!("Identification",                      sds.identification);
132        check_opt!("HazardIdentification",                sds.hazard_identification);
133        check_opt!("Composition",                         sds.composition);
134        check_opt!("FirstAidMeasures",                    sds.first_aid_measures);
135        check_opt!("FireFightingMeasures",                sds.fire_fighting_measures);
136        check_opt!("AccidentalReleaseMeasures",           sds.accidental_release_measures);
137        check_opt!("HandlingAndStorage",                  sds.handling_and_storage);
138        check_opt!("ExposureControlPersonalProtection",   sds.exposure_control_personal_protection);
139        check_opt!("PhysicalChemicalProperties",          sds.physical_chemical_properties);
140        check_opt!("StabilityReactivity",                 sds.stability_reactivity);
141        check_vec!("ToxicologicalInformation",            sds.toxicological_information);
142        check_vec!("EcologicalInformation",               sds.ecological_information);
143        check_opt!("DisposalConsiderations",              sds.disposal_considerations);
144        check_opt!("TransportInformation",                sds.transport_information);
145        check_opt!("RegulatoryInformation",               sds.regulatory_information);
146        check_opt!("OtherInformation",                    sds.other_information);
147
148        let standardization_notes = match source_language {
149            Language::Japanese => vec![
150                "Section headings follow JIS Z 7253:2019. \
151                 Section 1 uses '化学品及び会社情報' (JIS Z 7253:2019); \
152                 older source documents that use '製品及び会社情報' are normalised automatically."
153                    .to_string(),
154            ],
155            Language::English => vec![
156                "Section headings follow GHS Rev.10 / ISO 11014.".to_string(),
157            ],
158            Language::ChineseSimplified => vec![
159                "Section headings follow GB/T 16483-2012.".to_string(),
160            ],
161            Language::ChineseTraditional => vec![
162                "Section headings follow CNS 15030.".to_string(),
163            ],
164        };
165
166        Self {
167            source_language: source_language.bcp47().to_string(),
168            language_auto_detected,
169            populated_sections: populated,
170            empty_sections: empty,
171            standardization_notes,
172            warnings,
173            compliance_diff: None, // filled by convert_to_json_with_report if country is known
174        }
175    }
176}
177
178pub use compliance::{ComplianceDiffReport, ComplianceGap, generate_compliance_diff};
179pub use corrector::{CorrectionConfig, CorrectionResult};
180pub use extractor::InputFormat;
181#[allow(deprecated)]
182pub use generator::generate_docx;
183pub use generator::render_docx;
184#[allow(deprecated)]
185pub use pdf::generate_pdf;
186pub use pdf::render_pdf;
187pub use llm::{
188    openai_compat_url, AnthropicBackend, AnyBackend, build_any_backend,
189    extract_sds_from_pdf_vision, LlmBackend, LlmConfig, OpenAiCompatBackend,
190};
191pub use template::fill_template;
192
193/// Configuration for document conversion.
194#[derive(Debug, Clone)]
195pub struct ConvertConfig {
196    /// Language hint for the source document. `None` = auto-detect.
197    pub source_language: Option<Language>,
198    /// Country/regulatory-region of the source SDS. `None` = inferred from language.
199    ///
200    /// When set, country-specific extraction rules are injected into the LLM prompt
201    /// (e.g. China requires 24-hour emergency contact), country-specific validation
202    /// checks are applied, and a compliance-gap report is generated alongside the JSON.
203    pub source_country: Option<SourceCountry>,
204    /// Language used for section headings in the generated document.
205    pub output_language: Language,
206    /// Maximum characters of extracted text sent to the LLM (quality control).
207    /// Defaults to 80,000 to capture all 16 SDS sections including transport/regulatory.
208    /// CLI overrides this via `--quality` (low=15k, medium=30k, high=60k).
209    pub max_chars: usize,
210    /// Opt-in validation-driven correction pass.
211    ///
212    /// When `Some`, a second targeted LLM call is made after the primary
213    /// extraction to fix any invalid GHS H/P-codes found by the validator.
214    /// CAS check-digit errors are corrected deterministically (no LLM call).
215    ///
216    /// `None` (the default) leaves the existing behavior completely unchanged.
217    pub correction: Option<CorrectionConfig>,
218}
219
220impl Default for ConvertConfig {
221    fn default() -> Self {
222        Self {
223            source_language: None,
224            source_country: None,
225            output_language: Language::default(),
226            max_chars: 80_000,
227            correction: None,
228        }
229    }
230}
231
232/// Extract text from a PDF or DOCX file and convert it to [`SdsRoot`] via LLM.
233///
234/// Returns `(SdsRoot, [`ConversionReport`])` with full extraction metadata.
235/// Use [`convert_to_json`] for the simpler `(SdsRoot, Vec<String>)` interface.
236pub async fn convert_to_json_with_report<B: LlmBackend + Sync>(
237    input_path: &Path,
238    backend: &B,
239    config: &ConvertConfig,
240) -> Result<(SdsRoot, ConversionReport), SdsError> {
241    let text = extractor::extract_text_limited(input_path, config.max_chars).await?;
242    if text.trim().is_empty() {
243        return Err(SdsError::Extract(
244            "No text extracted — document may be image-only or empty".into(),
245        ));
246    }
247    let language_auto_detected = config.source_language.is_none();
248
249    // Resolve the effective source country: explicit override → inferred from language → None.
250    let effective_lang_for_country = config.source_language
251        .unwrap_or_else(|| crate::language::detect_language(&text));
252    let country = config.source_country
253        .or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
254
255    let (mut sds, mut warnings) =
256        llm::extract_sds_from_text(backend, &text, config.source_language, country).await?;
257
258    // ── Structural normalization ──────────────────────────────────────────────
259    // CAS numbers: split entries that contain embedded newlines (LLM sometimes
260    // concatenates multiple CAS numbers into one string with newlines instead
261    // of using separate list entries).
262    normalize_cas_full_text(&mut sds);
263
264    // HazardIdentification: always present, even for non-hazardous products.
265    // The LLM sometimes omits it entirely for HDPE/inert gas/food-grade substances;
266    // insert a minimal stub so downstream tools and the QC checker see a valid section.
267    ensure_hazard_identification(&mut sds);
268
269    // ── Optional correction pass (opt-in via ConvertConfig::correction) ───────
270    let mut corrected_cas_values: Vec<String> = Vec::new();
271    if let Some(correction_cfg) = &config.correction {
272        let findings = validator::collect_findings(&sds);
273        if !findings.is_empty() {
274            let result = corrector::apply_correction_pass(
275                sds, &text, &findings, backend, correction_cfg,
276            )
277            .await;
278            sds = result.sds;
279            warnings.extend(result.notes);
280            corrected_cas_values = result.corrected_cas_values;
281        }
282    }
283
284    let validation_warnings = validator::validate(&sds);
285    warnings.extend(validation_warnings);
286
287    // Country-specific validation (always-on when country is known).
288    if let Some(c) = country {
289        warnings.extend(validator::validate_country(&sds, c));
290    }
291
292    // Phase 2: deterministic source-text verification (zero extra API calls).
293    let source_warnings = validator::verify_against_source(&sds, &text, &corrected_cas_values);
294    warnings.extend(source_warnings);
295
296    // Determine effective source language for the report.
297    let effective_lang = config.source_language.unwrap_or_else(|| {
298        crate::language::detect_language(&text)
299    });
300    let mut report = ConversionReport::from_sds(&sds, effective_lang, language_auto_detected, warnings);
301
302    // Attach compliance diff to report when a country is known.
303    if let Some(c) = country {
304        report.compliance_diff = Some(compliance::generate_compliance_diff(&sds, c));
305    }
306
307    Ok((sds, report))
308}
309
310/// Extract text from a PDF or DOCX file and convert it to [`SdsRoot`] via LLM.
311///
312/// Returns `(SdsRoot, Vec<String>)` where the `Vec` contains any warnings:
313/// sections skipped due to schema mismatch, and structural validation issues.
314///
315/// For richer metadata (detected language, empty sections, standardisation notes)
316/// use [`convert_to_json_with_report`] instead.
317pub async fn convert_to_json<B: LlmBackend + Sync>(
318    input_path: &Path,
319    backend: &B,
320    config: &ConvertConfig,
321) -> Result<(SdsRoot, Vec<String>), SdsError> {
322    let (sds, report) = convert_to_json_with_report(input_path, backend, config).await?;
323    Ok((sds, report.warnings))
324}
325
326/// Convert raw document bytes to [`SdsRoot`] via LLM, returning a [`ConversionReport`].
327///
328/// This is the primary entry point for web / API callers that receive file bytes directly.
329/// For the simpler `(SdsRoot, Vec<String>)` interface use [`convert_bytes_to_json`].
330pub async fn convert_bytes_to_json_with_report<B: LlmBackend + Sync>(
331    data: &[u8],
332    filename: &str,
333    backend: &B,
334    config: &ConvertConfig,
335) -> Result<(SdsRoot, ConversionReport), SdsError> {
336    let suffix = Path::new(filename)
337        .extension()
338        .and_then(|e| e.to_str())
339        .map(|e| format!(".{}", e.to_ascii_lowercase()))
340        .unwrap_or_default();
341
342    let data_owned = data.to_vec();
343    let tmp = tokio::task::spawn_blocking(move || {
344        use std::io::Write as _;
345        let mut f = tempfile::Builder::new()
346            .suffix(&suffix)
347            .tempfile()
348            .map_err(|e| SdsError::Extract(format!("tempfile create: {e}")))?;
349        f.write_all(&data_owned)
350            .map_err(|e| SdsError::Extract(format!("tempfile write: {e}")))?;
351        f.flush()
352            .map_err(|e| SdsError::Extract(format!("tempfile flush: {e}")))?;
353        Ok::<_, SdsError>(f.into_temp_path())
354    })
355    .await
356    .map_err(|e| SdsError::Extract(format!("spawn_blocking panicked: {e}")))??;
357
358    convert_to_json_with_report(tmp.as_ref(), backend, config).await
359}
360
361/// Convert raw document bytes to [`SdsRoot`] via LLM.
362///
363/// Writes the bytes to a temporary file (preserving the original extension for format
364/// detection), then delegates to [`convert_bytes_to_json_with_report`].
365///
366/// For richer metadata use [`convert_bytes_to_json_with_report`] instead.
367pub async fn convert_bytes_to_json<B: LlmBackend + Sync>(
368    data: &[u8],
369    filename: &str,
370    backend: &B,
371    config: &ConvertConfig,
372) -> Result<(SdsRoot, Vec<String>), SdsError> {
373    let (sds, report) =
374        convert_bytes_to_json_with_report(data, filename, backend, config).await?;
375    Ok((sds, report.warnings))
376}
377
378/// Split any CAS `full_text` list entries that contain embedded newlines or
379/// commas into separate entries.  The LLM occasionally concatenates multiple
380/// CAS numbers into one string (e.g. `"590-00-1\n24634-61-5"` or
381/// `"64742-54-7, 64742-55-8, 64742-65-0"`) instead of using a list.
382fn normalize_cas_full_text(sds: &mut SdsRoot) {
383    let Some(comp) = &mut sds.composition else { return };
384    let Some(items) = &mut comp.composition_and_concentration else { return };
385    for item in items.iter_mut() {
386        let Some(ids) = &mut item.substance_identifiers else { continue };
387        let Some(identity) = &mut ids.substance_identity else { continue };
388        let Some(cas_node) = &mut identity.ca_sno else { continue };
389        let Some(texts) = &mut cas_node.full_text else { continue };
390
391        // A single valid CAS number never contains newlines, carriage returns,
392        // or commas.  Any such character signals a concatenated multi-CAS string.
393        let needs_split = texts.iter().any(|s| {
394            s.contains('\n') || s.contains('\r') || s.contains(',') || s.contains(';')
395        });
396        if !needs_split {
397            continue;
398        }
399
400        let expanded: Vec<String> = texts
401            .drain(..)
402            .flat_map(|s| {
403                s.split(|c: char| c == '\n' || c == '\r' || c == ',' || c == ';')
404                    .map(str::trim)
405                    .filter(|t| !t.is_empty())
406                    .map(str::to_string)
407                    .collect::<Vec<_>>()
408            })
409            .collect();
410        *texts = expanded;
411    }
412}
413
414/// Ensure `HazardIdentification` is always present in the SDS.
415///
416/// The LLM sometimes omits the section entirely for products with no GHS
417/// hazard classification (non-hazardous polymers, inert gases, food-grade
418/// substances).  Insert a minimal stub so the section is structurally valid:
419/// `HazardLabelling.SignalWord = "N/A"` and an empty `HazardStatement` list.
420fn ensure_hazard_identification(sds: &mut SdsRoot) {
421    if sds.hazard_identification.is_some() {
422        return;
423    }
424    use crate::schema::{
425        HazardIdentification, HazardIdentificationHazardLabelling,
426    };
427    sds.hazard_identification = Some(HazardIdentification {
428        hazard_labelling: Some(HazardIdentificationHazardLabelling {
429            signal_word: Some("N/A".to_string()),
430            hazard_statement: Some(vec![]),
431            ..Default::default()
432        }),
433        ..Default::default()
434    });
435}
436
437/// Convert an [`SdsRoot`] to a `.docx` file using the built-in layout.
438pub fn convert_from_json(
439    sds: &SdsRoot,
440    output_path: &Path,
441    config: &ConvertConfig,
442) -> Result<(), SdsError> {
443    render_docx(sds, output_path, config.output_language)
444}
445
446/// Fetch an HTML page from `url`, extract its text, and convert it to [`SdsRoot`] via LLM.
447pub async fn convert_url_to_json<B: LlmBackend + Sync>(
448    url: &str,
449    backend: &B,
450    config: &ConvertConfig,
451) -> Result<(SdsRoot, Vec<String>), SdsError> {
452    let text = extractor::extract_text_from_url_limited(url, config.max_chars).await?;
453    if text.trim().is_empty() {
454        return Err(SdsError::Extract(
455            "No text extracted from URL — page may be empty or JavaScript-rendered".into(),
456        ));
457    }
458    let effective_lang_for_country = config.source_language
459        .unwrap_or_else(|| crate::language::detect_language(&text));
460    let country = config.source_country
461        .or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
462    let (sds, mut warnings) =
463        llm::extract_sds_from_text(backend, &text, config.source_language, country).await?;
464    let validation_warnings = validator::validate(&sds);
465    warnings.extend(validation_warnings);
466    if let Some(c) = country {
467        warnings.extend(validator::validate_country(&sds, c));
468    }
469    Ok((sds, warnings))
470}
471
472/// Read a PDF file and extract SDS data using Anthropic's native PDF document API.
473///
474/// This bypasses text extraction entirely — the raw PDF bytes are base64-encoded and passed
475/// directly to the model as a document content block. Use this as a fallback when
476/// [`convert_to_json`] fails with [`crate::error::SdsError::ImageOnlyPdf`] (i.e. the PDF is
477/// image-only and tesseract is not installed).
478///
479/// Size limit: 32 MB. Requires an Anthropic API key and a `claude-*` model.
480pub async fn convert_pdf_to_json_vision(
481    input_path: &Path,
482    api_key: &str,
483    llm_config: &LlmConfig,
484    config: &ConvertConfig,
485) -> Result<(SdsRoot, Vec<String>), SdsError> {
486    let path_owned = input_path.to_path_buf();
487    let bytes = tokio::task::spawn_blocking(move || std::fs::read(&path_owned))
488        .await
489        .map_err(|e| SdsError::Extract(format!("spawn_blocking panicked: {e}")))?
490        .map_err(|e| SdsError::Extract(format!("reading PDF: {e}")))?;
491    let effective_lang_for_country = config.source_language
492        .unwrap_or_else(|| crate::language::detect_language(
493            &String::from_utf8_lossy(&bytes)
494        ));
495    let country = config.source_country
496        .or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
497    let (sds, mut warnings) =
498        llm::extract_sds_from_pdf_vision(api_key, llm_config, &bytes, config.source_language, country)
499            .await?;
500    let validation_warnings = validator::validate(&sds);
501    warnings.extend(validation_warnings);
502    if let Some(c) = country {
503        warnings.extend(validator::validate_country(&sds, c));
504    }
505    Ok((sds, warnings))
506}
507
508/// Fill a Word template (`.docx`) with data from an [`SdsRoot`].
509///
510/// Placeholders in the template use `{{FieldName}}` syntax where `FieldName` is
511/// the leaf key from the MHLW JSON schema (e.g. `{{TradeNameJP}}`,
512/// `{{CompanyName}}`). Full dot-path keys are also accepted for disambiguation
513/// (e.g. `{{Identification.SupplierInformation.CompanyName}}`).
514pub fn convert_from_template(
515    sds: &SdsRoot,
516    template_path: &Path,
517    output_path: &Path,
518) -> Result<(), SdsError> {
519    fill_template(sds, template_path, output_path)
520}