use serde::Deserialize;
use tracing::warn;
use crate::ghs_codes;
use crate::schema::SdsRoot;
use super::llm::LlmBackend;
use super::validator::{PCodeCategory, ValidationFinding};
#[derive(Debug, Clone, Default)]
pub struct CorrectionConfig {
}
pub struct CorrectionResult {
pub sds: SdsRoot,
pub notes: Vec<String>,
pub corrected_cas_values: Vec<String>,
}
pub async fn apply_correction_pass<B: LlmBackend + Sync>(
mut sds: SdsRoot,
source_text: &str,
findings: &[ValidationFinding],
backend: &B,
_config: &CorrectionConfig,
) -> CorrectionResult {
let mut notes: Vec<String> = Vec::new();
let mut corrected_cas_values: Vec<String> = Vec::new();
let cas_findings: Vec<&ValidationFinding> = findings
.iter()
.filter(|f| matches!(f, ValidationFinding::CasCheckDigit { .. }))
.collect();
let code_findings: Vec<&ValidationFinding> = findings
.iter()
.filter(|f| {
matches!(
f,
ValidationFinding::UnknownHCode { .. } | ValidationFinding::UnknownPCode { .. }
)
})
.collect();
for finding in &cas_findings {
if let ValidationFinding::CasCheckDigit {
cas,
composition_index,
expected_digit,
} = finding
{
if let Some(comp) = &mut sds.composition {
if let Some(items) = &mut comp.composition_and_concentration {
if let Some(item) = items.get_mut(*composition_index) {
if let Some(ids) = &mut item.substance_identifiers {
if let Some(identity) = &mut ids.substance_identity {
if let Some(cas_node) = &mut identity.ca_sno {
if let Some(texts) = &mut cas_node.full_text {
for text in texts.iter_mut() {
if text == cas {
let fixed = fix_cas_check_digit(cas, *expected_digit);
notes.push(format!(
"CAS corrected: '{cas}' → '{fixed}' \
(composition[{composition_index}])"
));
corrected_cas_values.push(fixed.clone());
*text = fixed;
}
}
}
}
}
}
}
}
}
}
}
if !code_findings.is_empty() {
match query_code_corrections(source_text, &code_findings, backend).await {
Ok(corrections) => {
apply_code_corrections(&mut sds, &code_findings, &corrections, &mut notes);
}
Err(e) => {
warn!("correction pass: LLM call failed — {e}");
notes.push(format!(
"Correction pass: LLM call failed ({e}); H/P-code corrections skipped."
));
}
}
}
CorrectionResult { sds, notes, corrected_cas_values }
}
fn fix_cas_check_digit(cas: &str, expected_digit: u32) -> String {
debug_assert!(cas.ends_with(|c: char| c.is_ascii_digit()));
let base = &cas[..cas.len().saturating_sub(1)];
format!("{base}{expected_digit}")
}
#[derive(Debug, Deserialize)]
struct CorrectionEntry {
original: String,
corrected: Option<String>,
#[allow(dead_code)]
reason: Option<String>,
}
async fn query_code_corrections<B: LlmBackend + Sync>(
source_text: &str,
findings: &[&ValidationFinding],
backend: &B,
) -> Result<Vec<CorrectionEntry>, String> {
let excerpt = extract_section2_excerpt(source_text);
let mut invalid_items = Vec::new();
for f in findings {
match f {
ValidationFinding::UnknownHCode { code, full_text, .. } => {
let item = serde_json::json!({
"type": "H-code",
"invalid": code,
"full_text": full_text.as_deref().unwrap_or("")
});
invalid_items.push(item);
}
ValidationFinding::UnknownPCode { code, full_text, category, .. } => {
let item = serde_json::json!({
"type": format!("P-code ({})", category),
"invalid": code,
"full_text": full_text.as_deref().unwrap_or("")
});
invalid_items.push(item);
}
_ => {}
}
}
let invalid_json = serde_json::to_string(&invalid_items)
.map_err(|e| format!("serialize invalid codes: {e}"))?;
let system = "You are a GHS hazard/precautionary code validator. \
Respond ONLY with a valid JSON array — no markdown, no prose, no code fences.";
let user = format!(
"Source document excerpt (Section 2, ≤500 chars):\n\
<excerpt>\n{excerpt}\n</excerpt>\n\n\
The following GHS codes were extracted but are not in the official GHS code list:\n\
{invalid_json}\n\n\
For each invalid code, reply with exactly one JSON object:\n\
{{\"original\": \"<invalid>\", \"corrected\": \"<valid GHS code>\", \"reason\": \"<brief>\"}}\n\
If no valid GHS code can be determined from the document, use null for \"corrected\".\n\
Reply ONLY with the JSON array."
);
let raw = backend.complete(system, &user).await.map_err(|e| e.to_string())?;
parse_correction_response(&raw)
}
fn parse_correction_response(raw: &str) -> Result<Vec<CorrectionEntry>, String> {
let trimmed = raw.trim();
let json_str = if trimmed.starts_with("```") {
trimmed
.lines()
.skip(1) .take_while(|l| !l.starts_with("```")) .collect::<Vec<_>>()
.join("\n")
} else {
trimmed.to_string()
};
let start = json_str.find('[').ok_or_else(|| "no '[' in LLM response".to_string())?;
let end = json_str.rfind(']').ok_or_else(|| "no ']' in LLM response".to_string())?;
if end < start {
return Err("malformed JSON array in LLM response".to_string());
}
let slice = &json_str[start..=end];
serde_json::from_str::<Vec<CorrectionEntry>>(slice)
.map_err(|e| format!("parse correction JSON: {e} — raw: {slice}"))
}
fn extract_section2_excerpt(source_text: &str) -> String {
let section2_markers = [
"第2", "Section 2", "2.", "危険有害性の要約",
"Hazard Identification", "危险性概述",
"危害辨識",
];
let section3_markers = [
"第3", "Section 3", "3.", "組成", "Composition", "成分",
];
let lower = source_text;
let start_pos = section2_markers
.iter()
.filter_map(|marker| lower.find(marker))
.min()
.unwrap_or(0);
let end_pos = section3_markers
.iter()
.filter_map(|marker| {
lower[start_pos..]
.find(marker)
.map(|p| p + start_pos)
})
.filter(|&p| p > start_pos + 10) .min()
.unwrap_or_else(|| (start_pos + 1000).min(source_text.len()));
let raw_excerpt = &source_text[start_pos..end_pos];
if raw_excerpt.len() <= 500 {
raw_excerpt.to_string()
} else {
let mut cut = 500;
while !raw_excerpt.is_char_boundary(cut) {
cut -= 1;
}
raw_excerpt[..cut].to_string()
}
}
fn apply_code_corrections(
sds: &mut SdsRoot,
findings: &[&ValidationFinding],
corrections: &[CorrectionEntry],
notes: &mut Vec<String>,
) {
use std::collections::HashMap;
let mut lookup: HashMap<&str, Option<&str>> = HashMap::new();
for entry in corrections {
lookup.insert(
entry.original.as_str(),
entry.corrected.as_deref(),
);
}
for finding in findings {
match finding {
ValidationFinding::UnknownHCode {
code,
statement_index,
..
} => {
let Some(corrected_opt) = lookup.get(code.as_str()) else {
notes.push(format!("H-code '{code}': not in LLM correction response; kept as-is."));
continue;
};
match corrected_opt {
None => {
if let Some(hz) = &mut sds.hazard_identification {
if let Some(hl) = &mut hz.hazard_labelling {
if let Some(stmts) = &mut hl.hazard_statement {
if *statement_index < stmts.len() {
notes.push(format!(
"H-code '{code}' deleted from HazardStatement[{statement_index}] \
(LLM: no valid replacement found)"
));
stmts.remove(*statement_index);
}
}
}
}
}
Some(new_code) => {
let upper = new_code.to_uppercase();
if !ghs_codes::is_valid_h_code(&upper) {
notes.push(format!(
"H-code '{code}': LLM suggested '{new_code}' which is also invalid; kept original."
));
continue;
}
if let Some(hz) = &mut sds.hazard_identification {
if let Some(hl) = &mut hz.hazard_labelling {
if let Some(stmts) = &mut hl.hazard_statement {
if let Some(stmt) = stmts.get_mut(*statement_index) {
notes.push(format!(
"H-code corrected: '{code}' → '{upper}' at HazardStatement[{statement_index}]"
));
stmt.hazard_statement_code = Some(upper);
}
}
}
}
}
}
}
ValidationFinding::UnknownPCode {
code,
category,
statement_index,
..
} => {
let Some(corrected_opt) = lookup.get(code.as_str()) else {
notes.push(format!("P-code '{code}': not in LLM correction response; kept as-is."));
continue;
};
match corrected_opt {
None => {
apply_p_code_delete(sds, code, category, *statement_index, notes);
}
Some(new_code) => {
let upper = new_code.to_uppercase();
if !ghs_codes::is_valid_p_code(&upper) {
notes.push(format!(
"P-code '{code}': LLM suggested '{new_code}' which is also invalid; kept original."
));
continue;
}
apply_p_code_replace(sds, code, category, *statement_index, &upper, notes);
}
}
}
ValidationFinding::CasCheckDigit { .. } => {
}
}
}
}
fn apply_p_code_delete(
sds: &mut SdsRoot,
code: &str,
category: &PCodeCategory,
idx: usize,
notes: &mut Vec<String>,
) {
let Some(hz) = &mut sds.hazard_identification else { return };
let Some(hl) = &mut hz.hazard_labelling else { return };
let Some(ps) = &mut hl.precautionary_statements else { return };
macro_rules! do_delete {
($list_field:expr) => {
if let Some(list) = $list_field.as_mut() {
if idx < list.len() {
notes.push(format!(
"P-code '{code}' deleted from PrecautionaryStatements.{category}[{idx}] \
(LLM: no valid replacement found)"
));
list.remove(idx);
}
}
};
}
match category {
PCodeCategory::Prevention => do_delete!(ps.prevention),
PCodeCategory::Response => do_delete!(ps.response),
PCodeCategory::Storage => do_delete!(ps.storage),
PCodeCategory::Disposal => do_delete!(ps.disposal),
}
}
fn apply_p_code_replace(
sds: &mut SdsRoot,
old_code: &str,
category: &PCodeCategory,
idx: usize,
new_code: &str,
notes: &mut Vec<String>,
) {
let Some(hz) = &mut sds.hazard_identification else { return };
let Some(hl) = &mut hz.hazard_labelling else { return };
let Some(ps) = &mut hl.precautionary_statements else { return };
macro_rules! do_replace {
($list_field:expr) => {
if let Some(list) = $list_field.as_mut() {
if let Some(entry) = list.get_mut(idx) {
notes.push(format!(
"P-code corrected: '{old_code}' → '{new_code}' \
at PrecautionaryStatements.{category}[{idx}]"
));
entry.precautionary_statement_code = Some(new_code.to_string());
}
}
};
}
match category {
PCodeCategory::Prevention => do_replace!(ps.prevention),
PCodeCategory::Response => do_replace!(ps.response),
PCodeCategory::Storage => do_replace!(ps.storage),
PCodeCategory::Disposal => do_replace!(ps.disposal),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fix_cas_check_digit_replaces_last_digit() {
assert_eq!(fix_cas_check_digit("238016-30-4", 3), "238016-30-3");
assert_eq!(fix_cas_check_digit("64-17-5", 5), "64-17-5");
assert_eq!(fix_cas_check_digit("7732-18-5", 5), "7732-18-5");
assert_eq!(fix_cas_check_digit("7732-18-9", 5), "7732-18-5");
}
#[test]
fn test_parse_correction_response_valid() {
let raw = r#"[{"original":"P286","corrected":"P285","reason":"typo"},{"original":"H999","corrected":null,"reason":"not found"}]"#;
let entries = parse_correction_response(raw).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].original, "P286");
assert_eq!(entries[0].corrected.as_deref(), Some("P285"));
assert_eq!(entries[1].original, "H999");
assert!(entries[1].corrected.is_none());
}
#[test]
fn test_parse_correction_response_with_fences() {
let raw = "```json\n[{\"original\":\"P289\",\"corrected\":\"P280\",\"reason\":\"closest match\"}]\n```";
let entries = parse_correction_response(raw).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].corrected.as_deref(), Some("P280"));
}
#[test]
fn test_extract_section2_excerpt_english() {
let text = "Product Name: Ethanol\n\
Section 1. Identification\n...\n\
Section 2. Hazard Identification\nFlammable liquid\nH225, H302\n\
Section 3. Composition\n...";
let excerpt = extract_section2_excerpt(text);
assert!(excerpt.contains("Hazard Identification") || excerpt.contains("H225"));
assert!(excerpt.len() <= 500);
}
}