Skip to main content

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