#!/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()