Skip to main content

sdsconv_core/converter/
corrector.rs

1//! Validation-driven correction pass.
2//!
3//! After the primary LLM extraction, [`apply_correction_pass`] looks at the
4//! typed [`ValidationFinding`]s returned by [`super::validator::collect_findings`]
5//! and applies targeted fixes:
6//!
7//! * **CAS check-digit mismatches** — fixed deterministically by recomputing the
8//!   last digit.  No second LLM call is needed.
9//! * **Unknown GHS H-codes / P-codes** — a single batched LLM call supplies
10//!   the source text excerpt so the model can propose corrections based on
11//!   what the original document actually says.
12//!
13//! The pass is **opt-in** (controlled by `ConvertConfig::correction`) and is
14//! **fail-safe**: any error (LLM failure, JSON parse failure, response
15//! validation failure) leaves the `SdsRoot` unchanged and appends a warning to
16//! the notes vector — the overall conversion never fails because of the
17//! correction pass.
18
19use serde::Deserialize;
20use tracing::warn;
21
22use crate::ghs_codes;
23use crate::schema::SdsRoot;
24
25use super::llm::LlmBackend;
26use super::validator::{PCodeCategory, ValidationFinding};
27
28// ---------------------------------------------------------------------------
29// Public configuration
30// ---------------------------------------------------------------------------
31
32/// Configuration for the optional validation-driven correction pass.
33///
34/// Currently no knobs are exposed; the struct exists so callers can opt in
35/// via `ConvertConfig::correction = Some(CorrectionConfig::default())` and
36/// future per-call overrides (e.g. model selection) can be added without a
37/// breaking change.
38#[derive(Debug, Clone, Default)]
39pub struct CorrectionConfig {
40    // Reserved for future extension (e.g. custom model override).
41}
42
43// ---------------------------------------------------------------------------
44// Public entry point
45// ---------------------------------------------------------------------------
46
47/// Apply a validation-driven correction pass to `sds`.
48///
49/// Returns `(corrected_sds, notes)` where `notes` is a list of human-readable
50/// messages describing each change made (or each error that prevented a
51/// correction).  On total LLM failure the original `sds` is returned unchanged.
52///
53/// # Arguments
54///
55/// * `sds` — the SdsRoot produced by the primary LLM extraction.
56/// * `source_text` — the raw text extracted from the source document, used
57///   to supply a Section 2 excerpt to the LLM so it can ground its corrections.
58/// * `findings` — output of [`super::validator::collect_findings`]; only
59///   `UnknownHCode`, `UnknownPCode`, and `CasCheckDigit` variants are acted on.
60/// * `backend` — any [`LlmBackend`] implementation.
61/// * `_config` — reserved for future per-call options.
62/// Return value of [`apply_correction_pass`].
63pub struct CorrectionResult {
64    pub sds: SdsRoot,
65    pub notes: Vec<String>,
66    /// CAS numbers that were deterministically corrected (new value after fix).
67    /// `verify_against_source` skips these to avoid false-positive SourceVerify warnings.
68    pub corrected_cas_values: Vec<String>,
69}
70
71pub async fn apply_correction_pass<B: LlmBackend + Sync>(
72    mut sds: SdsRoot,
73    source_text: &str,
74    findings: &[ValidationFinding],
75    backend: &B,
76    _config: &CorrectionConfig,
77) -> CorrectionResult {
78    let mut notes: Vec<String> = Vec::new();
79    let mut corrected_cas_values: Vec<String> = Vec::new();
80
81    // ── Partition findings ────────────────────────────────────────────────────
82    let cas_findings: Vec<&ValidationFinding> = findings
83        .iter()
84        .filter(|f| matches!(f, ValidationFinding::CasCheckDigit { .. }))
85        .collect();
86    let code_findings: Vec<&ValidationFinding> = findings
87        .iter()
88        .filter(|f| {
89            matches!(
90                f,
91                ValidationFinding::UnknownHCode { .. } | ValidationFinding::UnknownPCode { .. }
92            )
93        })
94        .collect();
95
96    // ── CAS: deterministic fix ────────────────────────────────────────────────
97    for finding in &cas_findings {
98        if let ValidationFinding::CasCheckDigit {
99            cas,
100            composition_index,
101            expected_digit,
102        } = finding
103        {
104            if let Some(comp) = &mut sds.composition {
105                if let Some(items) = &mut comp.composition_and_concentration {
106                    if let Some(item) = items.get_mut(*composition_index) {
107                        if let Some(ids) = &mut item.substance_identifiers {
108                            if let Some(identity) = &mut ids.substance_identity {
109                                if let Some(cas_node) = &mut identity.ca_sno {
110                                    if let Some(texts) = &mut cas_node.full_text {
111                                        for text in texts.iter_mut() {
112                                            if text == cas {
113                                                let fixed = fix_cas_check_digit(cas, *expected_digit);
114                                                notes.push(format!(
115                                                    "CAS corrected: '{cas}' → '{fixed}' \
116                                                     (composition[{composition_index}])"
117                                                ));
118                                                corrected_cas_values.push(fixed.clone());
119                                                *text = fixed;
120                                            }
121                                        }
122                                    }
123                                }
124                            }
125                        }
126                    }
127                }
128            }
129        }
130    }
131
132    // ── H/P-codes: LLM batch correction ──────────────────────────────────────
133    if !code_findings.is_empty() {
134        match query_code_corrections(source_text, &code_findings, backend).await {
135            Ok(corrections) => {
136                apply_code_corrections(&mut sds, &code_findings, &corrections, &mut notes);
137            }
138            Err(e) => {
139                warn!("correction pass: LLM call failed — {e}");
140                notes.push(format!(
141                    "Correction pass: LLM call failed ({e}); H/P-code corrections skipped."
142                ));
143            }
144        }
145    }
146
147    CorrectionResult { sds, notes, corrected_cas_values }
148}
149
150// ---------------------------------------------------------------------------
151// CAS deterministic fix
152// ---------------------------------------------------------------------------
153
154/// Replace the check digit of a CAS number with `expected_digit`.
155///
156/// Assumes `cas` has already been validated as format-correct (i.e. it ends
157/// with exactly one digit character).
158fn fix_cas_check_digit(cas: &str, expected_digit: u32) -> String {
159    debug_assert!(cas.ends_with(|c: char| c.is_ascii_digit()));
160    let base = &cas[..cas.len().saturating_sub(1)];
161    format!("{base}{expected_digit}")
162}
163
164// ---------------------------------------------------------------------------
165// H/P-code LLM batch correction
166// ---------------------------------------------------------------------------
167
168/// One entry in the correction response array returned by the LLM.
169#[derive(Debug, Deserialize)]
170struct CorrectionEntry {
171    original: String,
172    /// `null` means the LLM could not find a valid replacement — delete entry.
173    corrected: Option<String>,
174    #[allow(dead_code)]
175    reason: Option<String>,
176}
177
178/// Call the LLM once with all invalid H/P codes and parse a JSON array of
179/// [`CorrectionEntry`] values.
180async fn query_code_corrections<B: LlmBackend + Sync>(
181    source_text: &str,
182    findings: &[&ValidationFinding],
183    backend: &B,
184) -> Result<Vec<CorrectionEntry>, String> {
185    let excerpt = extract_section2_excerpt(source_text);
186
187    // Build a compact JSON array of the invalid codes with context.
188    let mut invalid_items = Vec::new();
189    for f in findings {
190        match f {
191            ValidationFinding::UnknownHCode { code, full_text, .. } => {
192                let item = serde_json::json!({
193                    "type": "H-code",
194                    "invalid": code,
195                    "full_text": full_text.as_deref().unwrap_or("")
196                });
197                invalid_items.push(item);
198            }
199            ValidationFinding::UnknownPCode { code, full_text, category, .. } => {
200                let item = serde_json::json!({
201                    "type": format!("P-code ({})", category),
202                    "invalid": code,
203                    "full_text": full_text.as_deref().unwrap_or("")
204                });
205                invalid_items.push(item);
206            }
207            _ => {}
208        }
209    }
210
211    let invalid_json = serde_json::to_string(&invalid_items)
212        .map_err(|e| format!("serialize invalid codes: {e}"))?;
213
214    let system = "You are a GHS hazard/precautionary code validator. \
215Respond ONLY with a valid JSON array — no markdown, no prose, no code fences.";
216
217    let user = format!(
218        "Source document excerpt (Section 2, ≤500 chars):\n\
219<excerpt>\n{excerpt}\n</excerpt>\n\n\
220The following GHS codes were extracted but are not in the official GHS code list:\n\
221{invalid_json}\n\n\
222For each invalid code, reply with exactly one JSON object:\n\
223  {{\"original\": \"<invalid>\", \"corrected\": \"<valid GHS code>\", \"reason\": \"<brief>\"}}\n\
224If no valid GHS code can be determined from the document, use null for \"corrected\".\n\
225Reply ONLY with the JSON array."
226    );
227
228    let raw = backend.complete(system, &user).await.map_err(|e| e.to_string())?;
229    parse_correction_response(&raw)
230}
231
232/// Parse the LLM correction response into a `Vec<CorrectionEntry>`.
233fn parse_correction_response(raw: &str) -> Result<Vec<CorrectionEntry>, String> {
234    // Strip optional markdown code fences.
235    let trimmed = raw.trim();
236    let json_str = if trimmed.starts_with("```") {
237        trimmed
238            .lines()
239            .skip(1) // skip opening ```
240            .take_while(|l| !l.starts_with("```")) // up to closing ```
241            .collect::<Vec<_>>()
242            .join("\n")
243    } else {
244        trimmed.to_string()
245    };
246
247    // Find the JSON array.
248    let start = json_str.find('[').ok_or_else(|| "no '[' in LLM response".to_string())?;
249    let end = json_str.rfind(']').ok_or_else(|| "no ']' in LLM response".to_string())?;
250    if end < start {
251        return Err("malformed JSON array in LLM response".to_string());
252    }
253
254    let slice = &json_str[start..=end];
255    serde_json::from_str::<Vec<CorrectionEntry>>(slice)
256        .map_err(|e| format!("parse correction JSON: {e} — raw: {slice}"))
257}
258
259/// Extract up to 500 characters from the Section 2 area of the source text.
260fn extract_section2_excerpt(source_text: &str) -> String {
261    // Try to find a Section 2 heading (Japanese, English, or Chinese).
262    let section2_markers = [
263        "第2", "Section 2", "2.", "危険有害性の要約",
264        "Hazard Identification", "危险性概述",
265        "危害辨識",
266    ];
267    let section3_markers = [
268        "第3", "Section 3", "3.", "組成", "Composition", "成分",
269    ];
270
271    let lower = source_text;
272    let start_pos = section2_markers
273        .iter()
274        .filter_map(|marker| lower.find(marker))
275        .min()
276        .unwrap_or(0);
277
278    // Find where Section 3 starts (to limit the excerpt).
279    let end_pos = section3_markers
280        .iter()
281        .filter_map(|marker| {
282            lower[start_pos..]
283                .find(marker)
284                .map(|p| p + start_pos)
285        })
286        .filter(|&p| p > start_pos + 10) // avoid matching the same heading
287        .min()
288        .unwrap_or_else(|| (start_pos + 1000).min(source_text.len()));
289
290    let raw_excerpt = &source_text[start_pos..end_pos];
291    // Limit to 500 bytes, but never split a multi-byte character.
292    if raw_excerpt.len() <= 500 {
293        raw_excerpt.to_string()
294    } else {
295        let mut cut = 500;
296        while !raw_excerpt.is_char_boundary(cut) {
297            cut -= 1;
298        }
299        raw_excerpt[..cut].to_string()
300    }
301}
302
303// ---------------------------------------------------------------------------
304// Apply LLM corrections to the SdsRoot
305// ---------------------------------------------------------------------------
306
307/// Apply the parsed [`CorrectionEntry`] list to the `sds`, guided by the
308/// original `findings` to navigate to the right field paths.
309fn apply_code_corrections(
310    sds: &mut SdsRoot,
311    findings: &[&ValidationFinding],
312    corrections: &[CorrectionEntry],
313    notes: &mut Vec<String>,
314) {
315    // Build a lookup: original_code → Option<corrected_code>
316    // (last entry wins if duplicates).
317    use std::collections::HashMap;
318    let mut lookup: HashMap<&str, Option<&str>> = HashMap::new();
319    for entry in corrections {
320        lookup.insert(
321            entry.original.as_str(),
322            entry.corrected.as_deref(),
323        );
324    }
325
326    for finding in findings {
327        match finding {
328            ValidationFinding::UnknownHCode {
329                code,
330                statement_index,
331                ..
332            } => {
333                let Some(corrected_opt) = lookup.get(code.as_str()) else {
334                    notes.push(format!("H-code '{code}': not in LLM correction response; kept as-is."));
335                    continue;
336                };
337
338                match corrected_opt {
339                    None => {
340                        // LLM says delete.
341                        if let Some(hz) = &mut sds.hazard_identification {
342                            if let Some(hl) = &mut hz.hazard_labelling {
343                                if let Some(stmts) = &mut hl.hazard_statement {
344                                    if *statement_index < stmts.len() {
345                                        notes.push(format!(
346                                            "H-code '{code}' deleted from HazardStatement[{statement_index}] \
347                                             (LLM: no valid replacement found)"
348                                        ));
349                                        stmts.remove(*statement_index);
350                                    }
351                                }
352                            }
353                        }
354                    }
355                    Some(new_code) => {
356                        let upper = new_code.to_uppercase();
357                        if !ghs_codes::is_valid_h_code(&upper) {
358                            notes.push(format!(
359                                "H-code '{code}': LLM suggested '{new_code}' which is also invalid; kept original."
360                            ));
361                            continue;
362                        }
363                        if let Some(hz) = &mut sds.hazard_identification {
364                            if let Some(hl) = &mut hz.hazard_labelling {
365                                if let Some(stmts) = &mut hl.hazard_statement {
366                                    if let Some(stmt) = stmts.get_mut(*statement_index) {
367                                        notes.push(format!(
368                                            "H-code corrected: '{code}' → '{upper}' at HazardStatement[{statement_index}]"
369                                        ));
370                                        stmt.hazard_statement_code = Some(upper);
371                                    }
372                                }
373                            }
374                        }
375                    }
376                }
377            }
378
379            ValidationFinding::UnknownPCode {
380                code,
381                category,
382                statement_index,
383                ..
384            } => {
385                let Some(corrected_opt) = lookup.get(code.as_str()) else {
386                    notes.push(format!("P-code '{code}': not in LLM correction response; kept as-is."));
387                    continue;
388                };
389
390                match corrected_opt {
391                    None => {
392                        // LLM says delete.
393                        apply_p_code_delete(sds, code, category, *statement_index, notes);
394                    }
395                    Some(new_code) => {
396                        let upper = new_code.to_uppercase();
397                        if !ghs_codes::is_valid_p_code(&upper) {
398                            notes.push(format!(
399                                "P-code '{code}': LLM suggested '{new_code}' which is also invalid; kept original."
400                            ));
401                            continue;
402                        }
403                        apply_p_code_replace(sds, code, category, *statement_index, &upper, notes);
404                    }
405                }
406            }
407
408            ValidationFinding::CasCheckDigit { .. } => {
409                // Already handled deterministically above.
410            }
411        }
412    }
413}
414
415/// Delete a P-code entry from the appropriate sub-list.
416///
417/// Each sub-list has a distinct concrete element type, so we use a macro to
418/// avoid repeating the remove logic four times.
419fn apply_p_code_delete(
420    sds: &mut SdsRoot,
421    code: &str,
422    category: &PCodeCategory,
423    idx: usize,
424    notes: &mut Vec<String>,
425) {
426    let Some(hz) = &mut sds.hazard_identification else { return };
427    let Some(hl) = &mut hz.hazard_labelling else { return };
428    let Some(ps) = &mut hl.precautionary_statements else { return };
429
430    macro_rules! do_delete {
431        ($list_field:expr) => {
432            if let Some(list) = $list_field.as_mut() {
433                if idx < list.len() {
434                    notes.push(format!(
435                        "P-code '{code}' deleted from PrecautionaryStatements.{category}[{idx}] \
436                         (LLM: no valid replacement found)"
437                    ));
438                    list.remove(idx);
439                }
440            }
441        };
442    }
443
444    match category {
445        PCodeCategory::Prevention => do_delete!(ps.prevention),
446        PCodeCategory::Response   => do_delete!(ps.response),
447        PCodeCategory::Storage    => do_delete!(ps.storage),
448        PCodeCategory::Disposal   => do_delete!(ps.disposal),
449    }
450}
451
452/// Replace a P-code value in the appropriate sub-list.
453///
454/// Each sub-list has a distinct concrete element type, so we use a macro to
455/// avoid repeating the field-update logic four times.
456fn apply_p_code_replace(
457    sds: &mut SdsRoot,
458    old_code: &str,
459    category: &PCodeCategory,
460    idx: usize,
461    new_code: &str,
462    notes: &mut Vec<String>,
463) {
464    let Some(hz) = &mut sds.hazard_identification else { return };
465    let Some(hl) = &mut hz.hazard_labelling else { return };
466    let Some(ps) = &mut hl.precautionary_statements else { return };
467
468    macro_rules! do_replace {
469        ($list_field:expr) => {
470            if let Some(list) = $list_field.as_mut() {
471                if let Some(entry) = list.get_mut(idx) {
472                    notes.push(format!(
473                        "P-code corrected: '{old_code}' → '{new_code}' \
474                         at PrecautionaryStatements.{category}[{idx}]"
475                    ));
476                    entry.precautionary_statement_code = Some(new_code.to_string());
477                }
478            }
479        };
480    }
481
482    match category {
483        PCodeCategory::Prevention => do_replace!(ps.prevention),
484        PCodeCategory::Response   => do_replace!(ps.response),
485        PCodeCategory::Storage    => do_replace!(ps.storage),
486        PCodeCategory::Disposal   => do_replace!(ps.disposal),
487    }
488}
489
490// ---------------------------------------------------------------------------
491// Unit tests
492// ---------------------------------------------------------------------------
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn test_fix_cas_check_digit_replaces_last_digit() {
500        assert_eq!(fix_cas_check_digit("238016-30-4", 3), "238016-30-3");
501        assert_eq!(fix_cas_check_digit("64-17-5", 5), "64-17-5");
502        assert_eq!(fix_cas_check_digit("7732-18-5", 5), "7732-18-5");
503        assert_eq!(fix_cas_check_digit("7732-18-9", 5), "7732-18-5");
504    }
505
506    #[test]
507    fn test_parse_correction_response_valid() {
508        let raw = r#"[{"original":"P286","corrected":"P285","reason":"typo"},{"original":"H999","corrected":null,"reason":"not found"}]"#;
509        let entries = parse_correction_response(raw).unwrap();
510        assert_eq!(entries.len(), 2);
511        assert_eq!(entries[0].original, "P286");
512        assert_eq!(entries[0].corrected.as_deref(), Some("P285"));
513        assert_eq!(entries[1].original, "H999");
514        assert!(entries[1].corrected.is_none());
515    }
516
517    #[test]
518    fn test_parse_correction_response_with_fences() {
519        let raw = "```json\n[{\"original\":\"P289\",\"corrected\":\"P280\",\"reason\":\"closest match\"}]\n```";
520        let entries = parse_correction_response(raw).unwrap();
521        assert_eq!(entries.len(), 1);
522        assert_eq!(entries[0].corrected.as_deref(), Some("P280"));
523    }
524
525    #[test]
526    fn test_extract_section2_excerpt_english() {
527        let text = "Product Name: Ethanol\n\
528Section 1. Identification\n...\n\
529Section 2. Hazard Identification\nFlammable liquid\nH225, H302\n\
530Section 3. Composition\n...";
531        let excerpt = extract_section2_excerpt(text);
532        assert!(excerpt.contains("Hazard Identification") || excerpt.contains("H225"));
533        assert!(excerpt.len() <= 500);
534    }
535}