1use 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#[derive(Debug, Clone, Default)]
39pub struct CorrectionConfig {
40 }
42
43pub struct CorrectionResult {
64 pub sds: SdsRoot,
65 pub notes: Vec<String>,
66 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 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 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 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
150fn 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#[derive(Debug, Deserialize)]
170struct CorrectionEntry {
171 original: String,
172 corrected: Option<String>,
174 #[allow(dead_code)]
175 reason: Option<String>,
176}
177
178async 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 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
232fn parse_correction_response(raw: &str) -> Result<Vec<CorrectionEntry>, String> {
234 let trimmed = raw.trim();
236 let json_str = if trimmed.starts_with("```") {
237 trimmed
238 .lines()
239 .skip(1) .take_while(|l| !l.starts_with("```")) .collect::<Vec<_>>()
242 .join("\n")
243 } else {
244 trimmed.to_string()
245 };
246
247 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
259fn extract_section2_excerpt(source_text: &str) -> String {
261 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 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) .min()
288 .unwrap_or_else(|| (start_pos + 1000).min(source_text.len()));
289
290 let raw_excerpt = &source_text[start_pos..end_pos];
291 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
303fn apply_code_corrections(
310 sds: &mut SdsRoot,
311 findings: &[&ValidationFinding],
312 corrections: &[CorrectionEntry],
313 notes: &mut Vec<String>,
314) {
315 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 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 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 }
411 }
412 }
413}
414
415fn 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
452fn 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#[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}