msds-tds/scripts/compliance-audit.py

808 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Compliance Audit Script for EU SDS PDFs (multi-language).
Checks PDFs against REACH Annex II and CLP requirements.
Usage:
python3 compliance-audit.py path/to/file.pdf
python3 compliance-audit.py path/to/directory/
python3 compliance-audit.py path/to/file.pdf --lang BG
python3 compliance-audit.py path/to/directory/ --lang auto
"""
import os, sys, re, json, glob
from datetime import datetime
try:
import pdfplumber
except ImportError:
print("❌ pdfplumber not found. Install: pip3 install pdfplumber --break-system-packages")
sys.exit(1)
# ---- Colour helpers ----
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
RESET = "\033[0m"
BOLD = "\033[1m"
def ok(t): return f"{GREEN}✅ {t}{RESET}"
def fail(t): return f"{RED}❌ {t}{RESET}"
def warn(t): return f"{YELLOW}🟡 {t}{RESET}"
# ---- CLP statement lookup ----
CLP_PATH = os.path.expanduser("~/msds-eu-translations/clp_statements_multilingual.json")
CLP_DATA = {}
if os.path.exists(CLP_PATH):
with open(CLP_PATH, encoding="utf-8") as f:
raw = json.load(f)
for stmt_type in ["h_statements", "euh_statements", "p_statements"]:
for code, translations in raw.get(stmt_type, {}).items():
CLP_DATA[code] = translations
# ============================================================
# LANGUAGE PROFILES
# ============================================================
# Each profile defines patterns for mandatory/optional checks
# that are specific to that language.
# Profiles can inherit from a base.
LANG_PROFILES = {
"EN": {
"name": "English",
"detect": [r"SECTION \d+", r"Safety Data Sheet", r"Regulation \(EC\)"],
"section_prefix": "SECTION",
"supplier_names": ["Synthesia"],
"section_checks": {
1: { # Identification
"mandatory": [
("Product name", r"(Ethyl acetate|Acetone|Methanol|Toluene|Butyl|Xylene|Chloroform|Glycerol|Formic acid|Sodium hydroxide)"),
("CAS number", r"\d{2,7}-\d{2}-\d"),
("EC number", r"\d{3}-\d{3}-\d"),
("REACH registration", r"01-\d{10,12}-\d{2}"),
("Supplier name present", r"Synthesia"),
("Supplier address", r"(Krivina|Todor Radunchev|Bulgaria)"),
("Emergency phone", r"112"),
],
"optional": [
("EC Index number", r"\d{3}-\d{3}-\d{2}-\d{2}"),
("Molecular formula", r"C\d+.*H\d+"),
]
},
2: {
"name": "Hazards",
"mandatory": [
("Classification table", r"(Hazard class|Category|H\d{3})"),
("Signal word", r"(DANGER|WARNING)"),
("Pictograms", r"GHS"),
("H-statements listed", r"H\d{3}"),
("PBT/vPvB statement", r"(PBT|vPvB|persistent)"),
("ED statement", r"(endocrine|ED)"),
],
"optional": [
("Precautionary statements", r"P\d{3}"),
("EUH-statements", r"EUH"),
("Explosive vapour warning", r"(explosive|vapour)"),
]
},
3: {
"name": "Composition",
"mandatory": [
("Component in table", r"(Ethyl acetate|Acetone|> \d+%)"),
("CAS in composition", r"\d{2,7}-\d{2}-\d"),
("Concentration", r">\s*\d+%"),
],
"optional": [
("CLP classification in table", r"(Flam\. Liq|Acute Tox|Skin Corr|Not classified)"),
]
},
4: {
"name": "First aid",
"mandatory": [
("Inhalation measures", r"(fresh air|inhalation)"),
("Skin contact measures", r"(skin|water|shower)"),
("Eye contact measures", r"(eye|rinse|contact lens)"),
("Ingestion measures", r"(ingestion|mouth|vomit)"),
],
"optional": [
("Symptoms table", r"(symptom|effect)"),
("Medical attention", r"(medical|physician|treat)"),
]
},
5: {
"name": "Firefighting",
"mandatory": [
("Extinguishing media", r"(water spray|dry powder|foam|CO₂|carbon dioxide)"),
("Fire hazard", r"(flammable|flammable liquid|not flammable)"),
],
"optional": [
("Decomposition products", r"(carbon oxides|decomposition)"),
("Firefighters equipment", r"(self-contained|SCBA)"),
]
},
6: {
"name": "Accidental release",
"mandatory": [
("Personal precautions", r"(PPE|protective|precaution|ventilat)"),
("Environmental precautions", r"(environment|avoid release|containment)"),
("Cleaning methods", r"(absorbent|clean|contain)"),
],
"optional": [
("Reference to other sections", r"(Section [78]|sections [78])"),
]
},
7: {
"name": "Handling & storage",
"mandatory": [
("Safe handling", r"(keep away|ground|bond|ventilation)"),
("Hygiene measures", r"(eat|drink|smoke|wash)"),
("Storage conditions", r"(store|well-ventilated|cool|tightly closed)"),
],
"optional": [
("Incompatible materials", r"(incompat|oxidiz)"),
]
},
8: {
"name": "Exposure controls",
"mandatory": [
("OEL/control parameter", r"(mg/m³|ppm|OEL|TWA|STEL)"),
("Hand protection", r"(glove|hand)"),
("Eye protection", r"(goggle|eye|face shield)"),
("Respiratory protection", r"(respirator|breathing|RPE)"),
],
"optional": [
("DNEL/DMEL table", r"DNEL"),
("PNEC table", r"PNEC"),
("Engineering controls", r"(ventilation|extract)"),
]
},
9: {
"name": "Physical/chemical",
"mandatory": [
("Physical state", r"(liquid|solid|gas|fluid)"),
("Boiling point", r"(boiling|°C|°F)"),
("Density", r"(g/cm³|density|g/ml)"),
("Flash point", r"(flash|°C)"),
],
"optional": [
("Colour/odour", r"(colour|odour|color|odor)"),
("Solubility", r"(solubility|water)"),
]
},
10: {
"name": "Stability/reactivity",
"mandatory": [
("Reactivity", r"(reactivity|reactive)"),
("Stability", r"(stable|stability)"),
],
"optional": [
("Conditions to avoid", r"(avoid|heat|flame)"),
("Incompatible materials", r"(incompatible|oxidiz)"),
("Decomposition products", r"(decomposition)"),
]
},
11: {
"name": "Toxicological",
"mandatory": [
("Acute toxicity info", r"(acute toxicity|toxicological)"),
("Classification", r"(classified|not classified)"),
],
"optional": [
("Skin corrosion/irritation", r"(skin|corrosion|irritation)"),
("Eye damage/irritation", r"(eye|irritation)"),
("STOT single exposure", r"STOT"),
("Carcinogenicity", r"(carcinogen|Carc\.)"),
("Reproductive toxicity", r"(reproductive|Repr\.)"),
("Mutagenicity", r"(mutagen|Germ cell)"),
("Aspiration hazard", r"(aspiration|Asp\.)"),
]
},
12: {
"name": "Ecological",
"mandatory": [
("Ecotoxicity info", r"(ecotox|ecological|aquatic)"),
("PBT/vPvB assessment", r"(PBT|vPvB)"),
],
"optional": [
("LC50/EC50 data", r"(LC50|EC50)"),
("Persistence/degradability", r"(biodegrad|persistence)"),
("Bioaccumulation", r"(bioaccumul|log Kow)"),
("Mobility in soil", r"(mobility|soil)"),
]
},
13: {
"name": "Disposal",
"mandatory": [
("Waste treatment", r"(dispose|waste|disposal)"),
],
"optional": [
("Packaging disposal", r"(packaging|container)"),
("EWC code", r"EWC|\d{2} \d{2} \d{2}"),
]
},
14: {
"name": "Transport",
"mandatory": [
("Transport info/ADR", r"(ADR|UN|transport|not classified as dangerous)"),
("UN number or N/A", r"(UN|not classified)"),
],
"optional": [
("Transport class", r"(Class|II|III|6\.1|8|9)"),
("Packing group", r"(II|III)"),
("Kemler number", r"(Kemler|33|30)"),
("Tunnel code", r"(tunnel|D/E)"),
("IMDG/IATA", r"(IMDG|IATA)"),
]
},
15: {
"name": "Regulatory",
"mandatory": [
("REACH Annex XVII", r"(Annex XVII|restriction)"),
("SVHC statement", r"SVHC"),
("WGK/national", r"(WGK|Water hazard|national regul)"),
("CSA statement", r"(chemical safety assessment|CSA)"),
],
"optional": [
("PIC regulation", r"PIC"),
("POP regulation", r"POP"),
]
},
16: {
"name": "Other info",
"mandatory": [
("H-statements full text", r"(Full text of H|H\d{3}.*—)"),
("Disclaimer", r"(Disclaimer|liability)"),
],
"optional": [
("Abbreviations", r"(abbreviat|acronym)"),
]
},
}
},
"BG": {
"name": "Български",
"detect": [r"РАЗДЕЛ \d+", r"ИНФОРМАЦИОНЕН ЛИСТ", r"Синтезия ООД"],
"section_prefix": "РАЗДЕЛ",
"supplier_names": ["Синтезия ООД"],
"section_checks": {
1: {"mandatory": [
("Product name (Cyrillic)", r"(Ацетон|Бутил|Етил|Метанол|Толуен|Хлороформ|Глицерин|Ксилен|Мравчена|Циклохексанон|Изобутанол|Натриева|Диетилен|Триетилен|Диоктил|Пропанол|Пропилен)"),
("CAS number", r"\d{2,7}-\d{2}-\d"),
("EC number", r"\d{3}-\d{3}-\d"),
("REACH registration", r"01-\d{10,12}-\d{2}"),
("Supplier: Синтезия ООД", r"Синтезия ООД"),
("Address (Кривина)", r"Кривина"),
("Emergency phone (112)", r"112"),
], "optional": [
("EC Index number", r"\d{3}-\d{3}-\d{2}-\d{2}"),
("Molecular formula", r"C\d+.*H\d+"),
]},
2: {"mandatory": [
("Classification table", r"(Клас на опасност|категория|H\d{3})"),
("Signal word", r"(ОПАСНОСТ|ВНИМАНИЕ)"),
("Pictograms", r"GHS"),
("H-statements", r"H\d{3}"),
("PBT/vPvB", r"(PBT|устойчив|биоакумулиращ)"),
("ED statement", r"(ендокринен|ED)"),
], "optional": [
("Precautionary statements", r"P\d{3}"),
("EUH-statements", r"EUH"),
("Explosive vapour", r"експлозивн"),
]},
3: {"mandatory": [
("Component in table", r"(Ацетон|Бутил|Етил|> 99%|Концентрация)"),
("CAS in composition", r"\d{2,7}-\d{2}-\d"),
("Concentration", r">\s*\d+%"),
], "optional": [
("Classification in comp", r"(Flam\. Liq|Acute Tox|Не е класифициран)"),
]},
4: {"mandatory": [
("Inhalation: fresh air", r"(чист въздух|вдишване)"),
("Skin: water", r"(кожата|вода)"),
("Eyes: rinse", r"(очите|промивайте|лещи)"),
("Ingestion: no vomiting", r"(повръщане|поглъщане)"),
], "optional": [
("Symptoms table", r"Симптоми"),
("Medical attention", r"медицинск"),
]},
5: {"mandatory": [
("Extinguishing media", r"(воден спрей|CO₂|пяна|сух прах)"),
("Fire hazard", r"(запалим|запалими|не е запалим)"),
], "optional": [
("Decomposition products", r"въглеродни оксиди"),
("Firefighters equip", r"автономен"),
]},
6: {"mandatory": [
("Personal precautions", r"(ЛПС|предпазни|спешни случаи)"),
("Environmental precautions", r"изпускане в околната среда"),
("Cleaning methods", r"(почистване|абсорбиращ)"),
], "optional": [
("Reference to other sections", r"7 и 8"),
]},
7: {"mandatory": [
("Safe handling", r"(пази|заземете|предпазни)"),
("Hygiene measures", r"(яде|пие|пуши)"),
("Storage conditions", r"(съхранява|проветриво)"),
], "optional": [
("Incompatible materials", r"несъвместим"),
]},
8: {"mandatory": [
("OEL/control parameter", r"(mg/m³|ppm|OEL)"),
("Hand protection (gloves)", r"ръкавици"),
("Eye protection (goggles)", r"очила"),
("Respiratory protection", r"дихателн"),
], "optional": [
("DNEL/DMEL tables", r"DNEL"),
("PNEC table", r"PNEC"),
("EN standards", r"EN \d+"),
("Engineering controls", r"вентилаци"),
]},
9: {"mandatory": [
("Physical state", r"(течност|твърд|газ)"),
("Boiling point", r"(°C|точка на кипене)"),
("Density", r"g/cm³"),
("Flash point", r"(°C|точка на запалване)"),
], "optional": [
("Colour/odour", r"(цвят|мирис|безцветен)"),
("Solubility", r"разтворимост"),
]},
10: {"mandatory": [
("Reactivity", r"реактивоспособност"),
("Stability", r"стабилен"),
], "optional": [
("Conditions to avoid", r"избягва"),
("Incompatible materials", r"несъвместим"),
("Decomposition", r"разпадане"),
]},
11: {"mandatory": [
("Acute toxicity", r"остра токсичност"),
("Classification", r"(класифицир|не се класифицир)"),
], "optional": [
("Skin corrosion/irritation", r"(дразнене|корози)"),
("Eye damage", r"очите"),
("STOT single", r"СТОО.*еднократн"),
("STOT repeated", r"СТОО.*многократн"),
("Carcinogenicity", r"Канцерогенност"),
("Reproductive toxicity", r"Репродуктивна токсичност"),
("Mutagenicity", r"Мутагенност"),
("Aspiration hazard", r"Аспирацион"),
("Sensitisation", r"Сенсибилизация"),
]},
12: {"mandatory": [
("Ecotoxicity info", r"Екологична информация"),
("PBT/vPvB conclusion", r"(PBT|vPvB)"),
], "optional": [
("LC50/EC50 data", r"(LC50|EC50)"),
("Persistence/degradability", r"биоразградим"),
("Bioaccumulation", r"биоакумул"),
("Mobility in soil", r"Мобилност в почвата"),
]},
13: {"mandatory": [
("Waste treatment", r"(изхвърлете|отпадъци|обезвреждане)"),
], "optional": [
("Packaging disposal", r"опаковк"),
("EWC code", r"EWC|07 01 04"),
]},
14: {"mandatory": [
("Transport info/ADR", r"(ADR|не е класифициран|транспорт)"),
("UN number or N/A", r"(UN|\d{4}|не е класифициран)"),
], "optional": [
("Transport class", r"(клас|II|III|6\.1|8|9)"),
("Packing group", r"(II|III)"),
("Kemler number", r"(Kemler|33|30)"),
("Tunnel code", r"(тунел|D/E)"),
("IMDG/IATA", r"(IMDG|IATA)"),
]},
15: {"mandatory": [
("REACH Annex XVII", r"Приложение XVII"),
("SVHC statement", r"SVHC"),
("WGK/national", r"WGK"),
("CSA statement", r"(оценка на химичната безопасност|CSA)"),
], "optional": [
("PIC regulation", r"PIC"),
("POP regulation", r"POP"),
]},
16: {"mandatory": [
("H-statements full text", r"(Пълен текст на H|H\d{3}.*—)"),
("Disclaimer", r"Отказ от отговорност"),
], "optional": [
("Abbreviations", r"Съкращени"),
]},
}
},
"RO": {
"name": "Română",
"detect": [r"SECȚIUNEA \d+", r"FIȘĂ CU DATE", r"Synthesia Grup"],
"section_prefix": "SECȚIUNEA",
"supplier_names": ["Synthesia Grup SRL"],
"section_checks": {
1: {"mandatory": [
("Product name", r"(Acetat de etil|Acetonă|Metanol|Toluen|Butanol|Xilen)"),
("CAS number", r"\d{2,7}-\d{2}-\d"),
("EC number", r"\d{3}-\d{3}-\d"),
("REACH registration", r"01-\d{10,12}-\d{2}"),
("Supplier: Synthesia Grup SRL", r"Synthesia Grup SRL"),
("Supplier address", r"(București|Dudesti|Pantelimon)"),
("Emergency phone", r"112"),
], "optional": [
("EC Index number", r"\d{3}-\d{3}-\d{2}-\d{2}"),
("Molecular formula", r"C\d+.*H\d+"),
]},
2: {"mandatory": [
("Classification table", r"(Clasa de pericol|Categoria|H\d{3})"),
("Signal word", r"(PERICOL|ATENȚIE)"),
("Pictograms", r"GHS"),
("H-statements", r"H\d{3}"),
("PBT/vPvB", r"(PBT|persisten|bioacumul)"),
("ED statement", r"(disruptor endocrin|ED)"),
], "optional": [
("Precautionary statements", r"P\d{3}"),
("EUH-statements", r"EUH"),
("Explosive vapour", r"exploziv"),
]},
3: {"mandatory": [
("Component in table", r"(Acetat|acetonă|> \d+%)"),
("CAS in composition", r"\d{2,7}-\d{2}-\d"),
("Concentration", r">\s*\d+%"),
], "optional": []},
4: {"mandatory": [
("Inhalation: fresh air", r"(aer liber|inhalare)"),
("Skin: water", r"(piele|apă)"),
("Eyes: rinse", r"(ochi|clăti|contact)"),
("Ingestion: no vomiting", r"(înghițire|gură|vărsă)"),
], "optional": [
("Symptoms table", r"Simptom"),
("Medical attention", r"medical|medic"),
]},
5: {"mandatory": [
("Extinguishing media", r"(apă pulverizat|pulbere uscat|spumă|CO₂)"),
("Fire hazard", r"(inflamabil|lichid.*vapori|nu este inflamabil)"),
], "optional": [
("Decomposition products", r"(oxizi de carbon|descompunere)"),
]},
6: {"mandatory": [
("Personal precautions", r"(protecție|echipament|ventilat)"),
("Environmental precautions", r"(evita|scurgeri|mediu)"),
("Cleaning methods", r"(absorbant|curățare|colecta)"),
], "optional": []},
7: {"mandatory": [
("Safe handling", r"(păstra|caldură|scântei|flacără)"),
("Hygiene measures", r"(mânca|bea|fuma|spăla)"),
("Storage conditions", r"(depozita|ventilat|răcoare)"),
], "optional": [
("Incompatible materials", r"incompatibil"),
]},
8: {"mandatory": [
("OEL/control parameter", r"(mg/m³|ppm|OEL)"),
("Hand protection", r"(mănuși|mâini)"),
("Eye protection", r"(ochelari|ochi)"),
("Respiratory protection", r"(respirator|respirație)"),
], "optional": [
("DNEL/DMEL tables", r"DNEL"),
("PNEC table", r"PNEC"),
]},
9: {"mandatory": [
("Physical state", r"(lichid|solid|gaz)"),
("Boiling point", r"(fierbere|°C)"),
("Density", r"(g/cm³|densitate)"),
("Flash point", r"(aprindere|°C)"),
], "optional": [
("Colour/odour", r"(culoare|miros|incolor)"),
("Solubility", r"solubilitate"),
]},
10: {"mandatory": [
("Reactivity", r"reactivitate"),
("Stability", r"stabil"),
], "optional": [
("Conditions to avoid", r"evit"),
("Incompatible materials", r"incompatibil"),
("Decomposition", r"descompunere"),
]},
11: {"mandatory": [
("Acute toxicity", r"toxicitate acută"),
("Classification", r"(clasificat|neclasificat)"),
], "optional": [
("Skin corrosion", r"(piele|corodare|iritare)"),
("Eye damage", r"ochilor"),
("STOT", r"STOT"),
("Carcinogenicity", r"cancerigenitate"),
("Reproductive toxicity", r"(reproducere|Repr\.)"),
("Aspiration hazard", r"aspirare"),
]},
12: {"mandatory": [
("Ecotoxicity info", r"ecologic"),
("PBT/vPvB assessment", r"(PBT|vPvB)"),
], "optional": [
("LC50/EC50 data", r"(LC50|EC50)"),
("Persistence/degradability", r"biodegradabil"),
("Bioaccumulation", r"bioacumulare"),
]},
13: {"mandatory": [
("Waste treatment", r"(deșeuri|elimin|arunca)"),
], "optional": []},
14: {"mandatory": [
("Transport info/ADR", r"(ADR|transport|clasificat ca periculos)"),
("UN number or N/A", r"(UN|clasificat ca periculos)"),
], "optional": [
("Transport class", r"(clasa|II|III|6\.1|8|9)"),
("Packing group", r"(II|III)"),
("IMDG/IATA", r"(IMDG|IATA)"),
]},
15: {"mandatory": [
("REACH Annex XVII", r"Anexa XVII"),
("SVHC statement", r"SVHC"),
("WGK/national", r"WGK"),
("CSA statement", r"(evaluare|siguranță chimică|CSA)"),
], "optional": []},
16: {"mandatory": [
("H-statements full text", r"(Textul integral|H\d{3}.*—)"),
("Disclaimer", r"(clauză de limitare|răspundere|declinare)"),
], "optional": [
("Abbreviations", r"abrevier"),
]},
}
}
}
def detect_language(pdf_text):
"""Auto-detect language from PDF text by looking for section markers."""
scores = {}
for lang_code, profile in LANG_PROFILES.items():
score = 0
for pattern in profile["detect"]:
if re.search(pattern, pdf_text):
score += 1
scores[lang_code] = score
if max(scores.values()) > 0:
return max(scores, key=scores.get)
return "EN" # default
def audit_pdf(pdf_path, lang_code=None):
"""Audit a single PDF against the spec for the detected/specified language."""
try:
with pdfplumber.open(pdf_path) as pdf:
full_text = "\n".join(p.extract_text() or "" for p in pdf.pages)
pages = len(pdf.pages)
except Exception as e:
return {"error": str(e), "grade": "FAIL", "file": os.path.basename(pdf_path)}
# Detect language if not specified
if not lang_code or lang_code == "auto":
detected = detect_language(full_text)
lang_code = detected or "EN"
else:
detected = lang_code
profile = LANG_PROFILES.get(lang_code, LANG_PROFILES["EN"])
results = {}
total_score = 0
total_max = 0
for sec_num in sorted(profile["section_checks"].keys()):
spec = profile["section_checks"][sec_num]
sec_name = spec.get("name", f"Section {sec_num}")
mandatory_total = len(spec["mandatory"])
mandatory_pass = 0
mandatory_issues = []
for check_name, pattern in spec["mandatory"]:
if re.search(pattern, full_text, re.IGNORECASE):
mandatory_pass += 1
else:
mandatory_issues.append(f"❌ Missing: {check_name}")
optional_total = len(spec["optional"])
optional_pass = 0
optional_issues = []
for check_name, pattern in spec["optional"]:
if re.search(pattern, full_text, re.IGNORECASE):
optional_pass += 1
else:
optional_issues.append(f"🟡 Missing: {check_name}")
sec_score = mandatory_pass * 2 + optional_pass
sec_max = mandatory_total * 2 + optional_total
pct = sec_score / sec_max * 100 if sec_max > 0 else 100
if pct >= 80: status = "✅ PASS"
elif pct >= 50: status = "🟡 BARELY"
else: status = "❌ FAIL"
total_score += sec_score
total_max += sec_max
results[sec_num] = {
"name": sec_name,
"mandatory": f"{mandatory_pass}/{mandatory_total}",
"optional": f"{optional_pass}/{optional_total}",
"score": f"{sec_score}/{sec_max} ({pct:.0f}%)",
"status": status,
"issues": mandatory_issues + optional_issues
}
overall_pct = total_score / total_max * 100 if total_max > 0 else 0
if overall_pct >= 85: grade = "PASS"
elif overall_pct >= 60: grade = "BARELY PASS"
else: grade = "NO PASS"
return {
"lang": detected,
"grade": grade,
"score": f"{total_score}/{total_max} ({overall_pct:.0f}%)",
"overall_pct": overall_pct,
"pages": pages,
"file": os.path.basename(pdf_path),
"sections": results,
}
def generate_report(pdf_path, result, verbose=False):
"""Generate markdown report for one PDF."""
name = os.path.basename(pdf_path).replace(".pdf", "")
lang_name = LANG_PROFILES.get(result["lang"], {}).get("name", result["lang"])
report = []
report.append(f"# Compliance Audit Report: {name}")
report.append(f"\n**Language:** {lang_name} ({result['lang']})")
report.append(f"**Grade:** {result['grade']}")
report.append(f"**Score:** {result['score']}")
report.append(f"**Pages:** {result['pages']}")
report.append(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
report.append(f"\n## Section Results\n")
report.append(f"| # | Section | Mandatory | Optional | Score | Status |")
report.append(f"|---|---------|-----------|----------|-------|--------|")
for sn in sorted(result["sections"].keys()):
s = result["sections"][sn]
report.append(f"| {sn} | {s['name']} | {s['mandatory']} | {s['optional']} | {s['score']} | {s['status']} |")
any_issues = False
report.append(f"\n## Issues\n")
for sn in sorted(result["sections"].keys()):
s = result["sections"][sn]
if s["issues"]:
any_issues = True
report.append(f"\n### Section {sn}: {s['name']}\n")
for iss in s["issues"]:
report.append(f"- {iss}")
if not any_issues:
report.append("No issues found.")
report.append(f"\n## ⚠️ Verification Required")
report.append(f"\nThis audit checks structural completeness. The following must be verified by a REACH expert:")
report.append(f"- REACH registration numbers — confirm against your REACH dossier")
report.append(f"- H-statements/classification — verify against CLP Annex VI")
report.append(f"- OEL values — confirm with national/local regulations")
report.append(f"- Transport data — verify against ADR")
report.append(f"- DNEL/PNEC values — verify against your CSR")
report.append(f"\n---\n*Compliance Auditor v2 | Hermes Agent*")
return "\n".join(report)
def main():
import argparse
parser = argparse.ArgumentParser(description="Compliance audit of SDS PDFs (multi-language)")
parser.add_argument("target", help="PDF file or directory to audit")
parser.add_argument("--lang", default="auto", choices=["auto", "EN", "BG", "RO"],
help="Language (default: auto-detect)")
parser.add_argument("--out", "-o", default=None, help="Output directory for reports")
args = parser.parse_args()
target = args.target
if os.path.isdir(target):
pdfs = sorted(glob.glob(os.path.join(target, "*.pdf")))
# Filter out non-SDS PDFs and old long-named duplicates
pdfs = [p for p in pdfs if "SDS" in os.path.basename(p) and not any(
x in os.path.basename(p) for x in ["DOTP.pdf", "DICHLOROMETHANE", "TETRAETHYL",
"MONOBUTYL", "2-BUTOXY", "AZEOTROPIC",
"DILUTE", "DIL.pdf", "_BUTANOL.pdf",
"PROPANOL.pdf", "ACETATE.pdf"])
and os.path.basename(p) not in [
"SDS_BG_N-BUTANOL.pdf", "SDS_BG_N-PROPANOL.pdf", "SDS_BG_N-PROPYL_ACETATE.pdf",
"SDS_BG_SODIUM_HYDROXIDE_SOLUTION_48%.pdf", "SDS_BG_SODIUM_HYDROXIDE_FLAKES.pdf"
]]
elif os.path.isfile(target):
pdfs = [target]
else:
print(f"Error: {target} not found")
sys.exit(1)
if not pdfs:
print("No PDF files found.")
sys.exit(1)
report_dir = args.out or os.path.join(os.path.dirname(pdfs[0]), "compliance-reports")
os.makedirs(report_dir, exist_ok=True)
# Version-tracked subdirectory
date_stamp = datetime.now().strftime("%Y-%m-%d")
versioned_dir = os.path.join(report_dir, date_stamp)
os.makedirs(versioned_dir, exist_ok=True)
print(f"\n{BOLD}Compliance Audit — EU Language SDS Checker{RESET}")
print(f"Files: {len(pdfs)} | Language: {args.lang}")
print("=" * 70)
all_results = []
grades_count = {"PASS": 0, "BARELY PASS": 0, "NO PASS": 0}
for pdf_path in pdfs:
result = audit_pdf(pdf_path, args.lang)
if "error" in result:
print(f" {fail('ERROR')}: {result['file']} — {result['error']}")
continue
icon = {"PASS": ok, "BARELY PASS": warn, "NO PASS": fail}[result["grade"]]
lang_tag = f"[{result['lang']}]"
print(f" {icon(result['grade']):15s} {lang_tag:5s} {result['file']:50s} ({result['pages']}p, {result['score']})")
grades_count[result["grade"]] = grades_count.get(result["grade"], 0) + 1
# Per-document report (save to both regular and versioned dirs)
report = generate_report(pdf_path, result)
rname = f"compliance-{os.path.basename(pdf_path).replace('.pdf','')}.md"
# Current
with open(os.path.join(report_dir, rname), 'w', encoding='utf-8') as f:
f.write(report)
# Versioned
with open(os.path.join(versioned_dir, rname), 'w', encoding='utf-8') as f:
f.write(report)
all_results.append(result)
# Summary
print(f"\n{'='*70}")
print(f"{BOLD}Summary{RESET}")
for g in ["PASS", "BARELY PASS", "NO PASS"]:
print(f" {g:15s}: {grades_count.get(g, 0)}")
print(f"{'Total':15s}: {sum(grades_count.values())}")
if all_results:
avg = sum(r["overall_pct"] for r in all_results if "overall_pct" in r) / len(all_results)
print(f"{'Average':15s}: {avg:.0f}%")
# Master report
master = [f"# Master Compliance Audit Report",
f"\n**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"**PDFs audited:** {len(all_results)}",
f"\n## Summary\n",
f"| Grade | Count |",
f"|-------|-------|"]
for g in ["PASS", "BARELY PASS", "NO PASS"]:
master.append(f"| {g} | {grades_count.get(g, 0)} |")
master.append(f"\n## Per-Product\n")
master.append(f"| # | File | Lang | Grade | Pages | Score |")
master.append(f"|---|------|------|-------|-------|-------|")
for i, r in enumerate(all_results, 1):
if "error" in r:
master.append(f"| {i} | {r['file']} | — | ERROR | — | — |")
else:
master.append(f"| {i} | {r['file']} | {r['lang']} | {r['grade']} | {r['pages']} | {r['score']} |")
master_path = os.path.join(os.path.dirname(pdfs[0]), "master-compliance-audit-report.md")
with open(master_path, 'w', encoding='utf-8') as f:
f.write("\n".join(master))
# Versioned copy
master_ver = os.path.join(versioned_dir, "master-compliance-audit-report.md")
with open(master_ver, 'w', encoding='utf-8') as f:
f.write("\n".join(master))
print(f"\n📋 Reports: {report_dir}/")
print(f"📊 Master: {master_path}")
if __name__ == "__main__":
main()