feat(literature): add trade-name resolver, Poucher/Arctander parsers, and PyMuPDF extraction scripts
0ebf67e unverified | from __future__ import annotations | |
| """ | |
| Resolve common fragrance trade names to CAS numbers for literature formula ingestion. | |
| Primary source: local SQLite registry (aroma_chemicals). | |
| Fallback: hardcoded mapping for trade names and naturals that the registry may not include. | |
| """ | |
| import re | |
| import sqlite3 | |
| from pathlib import Path | |
| from typing import Any | |
| from rapidfuzz import fuzz, process | |
| TRADE_TO_CAS: dict[str, str] = { | |
| # Common trade / natural names | |
| "rosewood": "8015-77-8", | |
| "rosewood oil": "8015-77-8", | |
| "bois de rose oil": "8015-77-8", | |
| "bergamot": "8007-75-8", | |
| "bergamot oil": "8007-75-8", | |
| "santal": "8006-87-9", | |
| "sandalwood": "8006-87-9", | |
| "sandalwood oil": "8006-87-9", | |
| "santalwood oil": "8006-87-9", | |
| "cedarwood": "8000-27-9", | |
| "cedarwood oil": "8000-27-9", | |
| "cedarwood oil, virginia": "8000-27-9", | |
| "lavender": "8000-28-0", | |
| "lavender oil": "8000-28-0", | |
| "lavender flowers": "8000-28-0", | |
| "lavender, spike": "8016-78-2", | |
| "spike lavender": "8016-78-2", | |
| "lavender oil, m.b": "8000-28-0", | |
| "lavender oil, french": "8000-28-0", | |
| "lavender oil, english": "8000-28-0", | |
| "lavandin": "93455-97-1", | |
| "geranium": "8000-46-2", | |
| "geranium oil": "8000-46-2", | |
| "geranium, bourbon": "8000-46-2", | |
| "geranium oil, bourbon": "8000-46-2", | |
| "geranium oil, algerian": "8000-46-2", | |
| "geranium, algerian": "8000-46-2", | |
| "rose otto": "8007-01-0", | |
| "rose absolute": "8007-01-0", | |
| "rose petals": "8007-01-0", | |
| "rose geranium oil": "8000-46-2", | |
| "rose geranium oil, french": "8000-46-2", | |
| "phenylethyl alcohol": "60-12-8", | |
| "phenyl ethyl alcohol": "60-12-8", | |
| "phenyl ethyl acetate": "103-45-7", | |
| "benzyl acetate": "140-11-4", | |
| "iso-butyl phenylacetate": "102-13-6", | |
| "isobutyl phenylacetate": "102-13-6", | |
| "methyl heptine carbonate": "111-12-6", | |
| "methyl heptin carbonate": "111-12-6", | |
| "methyl nonyl acetaldehyde": "112-12-9", | |
| "aldehyde c 11": "112-44-7", | |
| "aldehyde c 12": "112-54-9", | |
| "aldehyde c 8": "124-13-0", | |
| "aldehyde c 10": "112-31-2", | |
| "aldehyde c 12": "112-54-9", | |
| "ald c 8": "124-13-0", | |
| "ald c 10": "112-31-2", | |
| "ald c 11": "112-44-7", | |
| "ald c 12": "112-54-9", | |
| "amyl cinnamic aldehyde": "122-40-7", | |
| "amyl salicylate": "2050-08-0", | |
| "anisic aldehyde": "123-11-5", | |
| "bromstyrole": "2039-67-0", | |
| "bromstyrol": "2039-67-0", | |
| "cananga": "68606-83-7", | |
| "cananga oil": "68606-83-7", | |
| "ylang-ylang oil": "8006-81-3", | |
| "ylang-ylang oil, bourbon": "8006-81-3", | |
| "cassia": "8007-80-5", | |
| "cassia oil": "8007-80-5", | |
| "ceylon cinnamon": "8007-80-5", | |
| "cinnamon ceylon": "8007-80-5", | |
| "clove": "8000-34-8", | |
| "clove oil": "8000-34-8", | |
| "clove bud oil": "8000-34-8", | |
| "patchouli": "8014-09-3", | |
| "patchouli oil": "8014-09-3", | |
| "patchouli leaves": "8014-09-3", | |
| "patchouli oil, english": "8014-09-3", | |
| "vetivert": "8016-96-4", | |
| "vetivert oil": "8016-96-4", | |
| "vetivert roots": "8016-96-4", | |
| "vetiver": "8016-96-4", | |
| "vetiver oil": "8016-96-4", | |
| "oakmoss": "9000-50-4", | |
| "oakmoss absolute": "9000-50-4", | |
| "oakmoss absolute, green": "9000-50-4", | |
| "mousse de saxe": "", | |
| "tonka beans": "9000-65-5", | |
| "tonka absolute": "9000-65-5", | |
| "coumarin": "91-64-5", | |
| "vanillin": "121-33-5", | |
| "heliotropin": "120-57-0", | |
| "piperonal": "120-57-0", | |
| "musk ambrette": "83-66-9", | |
| "musk ketone": "81-14-1", | |
| "musk xylene": "81-15-2", | |
| "musk xylol": "81-15-2", | |
| "musk residues": "", | |
| "musk tincture, 3 per cent": "", | |
| "civet extract, 3 per cent": "68916-26-7", | |
| "civet absolute": "68916-26-7", | |
| "civet": "68916-26-7", | |
| "ambergris extract, 3 per cent": "9000-64-2", | |
| "ambergris": "9000-64-2", | |
| "ambre": "", | |
| "ambrene": "", | |
| "ambroxan": "6790-58-5", | |
| "siam benzoin": "9000-64-0", | |
| "benzoin resin": "9000-64-0", | |
| "benzoin tincture, 10 per cent": "9000-64-0", | |
| "peru balsam": "8007-00-9", | |
| "peru balsam oil": "8007-00-9", | |
| "peru balsam": "8007-00-9", | |
| "tolu balsam": "9000-64-0", | |
| "tolu balsam oil": "9000-64-0", | |
| "styrax resin": "8046-19-3", | |
| "styrax absolute": "8046-19-3", | |
| "liquidambar resin": "8024-13-1", | |
| "olibanum resin": "8050-07-5", | |
| "frankincense": "8050-07-5", | |
| "opopanax": "8023-89-0", | |
| "myrrh": "9000-45-9", | |
| "galbanum resin": "8023-91-4", | |
| "galbanum oil": "8023-91-4", | |
| "orris": "8002-73-1", | |
| "orris root": "8002-73-1", | |
| "orris concrete": "8002-73-1", | |
| "orris oil": "8002-73-1", | |
| "orris oil, concrete": "8002-73-1", | |
| "orris resin": "8002-73-1", | |
| "tincture of orris": "8002-73-1", | |
| "tincture of vanilla": "8047-24-3", | |
| "vanilla essence, 10 per cent": "8047-24-3", | |
| "tincture of tonka": "9000-65-5", | |
| "tincture of benzoin": "9000-64-0", | |
| "tincture of tolu, 10 per cent": "9000-64-0", | |
| "jasmine absolute": "8022-96-6", | |
| "jasmin absolute": "8022-96-6", | |
| "jasmine": "8022-96-6", | |
| "jasmin": "8022-96-6", | |
| "jasmine compound": "8022-96-6", | |
| "jasmin compound": "8022-96-6", | |
| "jasmin, compound": "8022-96-6", | |
| "neroli oil": "8016-38-4", | |
| "neroli oil, bigarade": "8016-38-4", | |
| "orange blossom absolute": "8016-38-4", | |
| "orange oil, sweet": "8008-57-9", | |
| "sweet orange oil": "8008-57-9", | |
| "sweet orange oil, 10 fold": "8008-57-9", | |
| "lemon oil": "8008-56-8", | |
| "lemon": "8008-56-8", | |
| "mandarin oil": "8008-31-9", | |
| "petitgrain": "8014-17-3", | |
| "petitgrain para": "8014-17-3", | |
| "petitgrain oil": "8014-17-3", | |
| "petitgrain oil, french": "8014-17-3", | |
| "petitgrain para": "8014-17-3", | |
| "bergamot": "8007-75-8", | |
| "citronella, java": "8000-29-1", | |
| "citronella, ceylon": "89997-75-1", | |
| "ceylon citronella": "89997-75-1", | |
| "lemongrass": "8007-02-1", | |
| "citral": "5392-40-5", | |
| "geraniol, java": "106-24-1", | |
| "geraniol": "106-24-1", | |
| "geranyl acetate": "105-87-3", | |
| "geranyl formate": "105-86-2", | |
| "linalool": "78-70-6", | |
| "linalyl acetate": "115-95-7", | |
| "citronellol": "106-22-9", | |
| "ionone": "8013-90-9", | |
| "methyl ionone": "1335-46-2", | |
| "ionone beta": "14901-07-6", | |
| "isoeugenol": "97-54-1", | |
| "iso-eugenol": "97-54-1", | |
| "benzyl iso-eugenol": "120-11-6", | |
| "eugenol": "97-53-0", | |
| "methyl anthranilate": "134-20-3", | |
| "dimethyl anthranilate": "606-09-1", | |
| "paracresyl acetate": "140-39-6", | |
| "p-cresyl acetate": "140-39-6", | |
| "amyl valerianate": "2173-56-0", | |
| "amyl valerate": "2173-56-0", | |
| "ethyl acetate": "141-78-6", | |
| "ethyl malonate": "105-53-3", | |
| "ethyl benzoate": "93-89-0", | |
| "ethyl butyrate": "105-54-4", | |
| "ethyl oenanthate": "123-66-0", | |
| "ethyl heptanoate": "123-66-0", | |
| "geranyl butyrate": "106-29-6", | |
| "acetaldehyde": "75-07-0", | |
| "chloroform": "67-66-3", | |
| " bornyl acetate": "76-49-3", | |
| "borneol": "507-70-0", | |
| "camphor": "76-22-2", | |
| "fir needle, siberian": "8021-29-2", | |
| "fir needle siberian": "8021-29-2", | |
| "sassafras": "8006-80-2", | |
| "sassafras oil": "8006-80-2", | |
| "thyme, red": "8007-46-3", | |
| "thyme oil": "8007-46-3", | |
| "thyme oil, red": "8007-46-3", | |
| "eucalyptus oil": "8000-48-4", | |
| "rosemary oil": "8000-25-7", | |
| "peppermint oil": "8006-90-4", | |
| "spearmint oil": "8008-79-5", | |
| "caraway": "85940-31-4", | |
| "caraway oil": "85940-31-4", | |
| "anise": "8007-70-3", | |
| "anise oil": "8007-70-3", | |
| "star anise": "8007-70-3", | |
| "fennel, sweet": "8006-84-6", | |
| "sweet fennel": "8006-84-6", | |
| "bitter almond": "8013-76-1", | |
| "almond oil, bitter s.a.p": "8013-76-1", | |
| "almond oil, bitter s.a.p.": "8013-76-1", | |
| "benzaldehyde": "100-52-7", | |
| "cinnamon bark oil": "8015-91-6", | |
| "cinnamon oil": "8007-80-5", | |
| "cinnamon oil, ceylon": "8007-80-5", | |
| "cassia oil": "8007-80-5", | |
| "nutmeg": "8008-45-5", | |
| "pimento": "8006-77-7", | |
| "allspice": "8006-77-7", | |
| "clove stem oil": "8000-34-8", | |
| "clove leaf oil": "8000-34-8", | |
| "mace": "8007-12-3", | |
| "cedarleaf oil": "8006-80-2", | |
| "cade oil": "8011-10-3", | |
| "birch tar": "84012-40-2", | |
| "guaiac wood": "8016-23-7", | |
| "guaiacwood oil": "8016-23-7", | |
| "rose ether": "141-12-8", | |
| "rhodinol": "6812-78-8", | |
| "dimethyl benzyl carbinol": "100-86-7", | |
| "phenyl ethyl dimethyl carbinol": "100-86-7", | |
| "hedione": "24851-98-7", | |
| "methyl dihydrojasmonate": "24851-98-7", | |
| "iso e super": "54464-57-2", | |
| "iso e super": "54464-57-2", | |
| "ambroxan": "6790-58-5", | |
| "ambrox": "6790-58-5", | |
| "galaxolide": "1222-05-5", | |
| "tibetine": "", | |
| "terpineol": "8000-41-7", | |
| "terpineol, alpha": "98-55-5", | |
| "hydroxy citronellal": "107-75-5", | |
| "cyclamen aldehyde": "103-95-7", | |
| "indole": "120-72-9", | |
| "terebene": "8032-35-1", | |
| "benzylidene acetone": "122-57-6", | |
| "musk extract, 3 per cent": "", | |
| "phenylethyl valerianate": "7462-86-6", | |
| "rhodinyl valerianate": "85409-36-9", | |
| "cascarilla oil": "8007-06-5", | |
| "nutmeg oil": "8008-45-5", | |
| "musk tincture, 3 per cent": "", | |
| "dimethyl hydroquinone": "150-78-7", | |
| "labdanum clair": "8016-26-0", | |
| "castoreum extract, 3 per cent": "8023-79-2", | |
| "clary sage, concrete": "8016-63-5", | |
| "clary sage concrete": "8016-63-5", | |
| "castoreum absolute": "8023-79-2", | |
| "castoreum": "8023-79-2", | |
| "mimosa absolute": "8031-03-6", | |
| "cassie absolute": "8015-61-0", | |
| "tuberose compound": "", | |
| "orange blossom compound": "", | |
| "apple blossom b": "", | |
| "fougere b": "", | |
| "muguet": "", | |
| "narcissus": "8023-75-2", | |
| "lily": "9000-40-2", | |
| "lily of the valley": "", | |
| "violet leaves absolute": "8024-08-6", | |
| "violet leaf absolute": "8024-08-6", | |
| "hawthorn": "", | |
| "sweet pea": "", | |
| "trefle": "", | |
| "tretle": "", | |
| "transparent": "", | |
| "pine bouquet": "", | |
| "shaving stick": "", | |
| "flake": "", | |
| "apple base": "", | |
| "chypre": "", | |
| "heliotrope": "", | |
| "carnation": "", | |
| "rose": "", | |
| "violet": "", | |
| "musk": "", | |
| "fougere": "", | |
| "amber": "", | |
| "oriental": "", | |
| "orange blossom": "8016-38-4", | |
| # Solvents / carriers not used in fragrance concentrate, skip | |
| "ethanol": "", | |
| "alcohol": "", | |
| "sd alcohol 38 f": "", | |
| "tincture of vanilla": "8047-24-3", | |
| "tincture of benzoin": "9000-64-0", | |
| "tincture of tonka": "9000-65-5", | |
| "tincture of orris": "8002-73-1", | |
| "tincture of tolu": "9000-64-0", | |
| "musk tincture, 3 per cent": "", | |
| "civet extract, 3 per cent": "68916-26-7", | |
| "ambergris extract, 3 per cent": "9000-64-2", | |
| } | |
| class TradeNameResolver: | |
| def __init__(self, registry_db: Path | str = "/home/hermes/pino/src/pino/registry.db"): | |
| self.registry: dict[str, str] = {} | |
| self.aliases: dict[str, str] = {} | |
| self._load_registry(registry_db) | |
| self._build_alias_index() | |
| def _load_registry(self, registry_db: Path | str) -> None: | |
| conn = sqlite3.connect(str(registry_db)) | |
| conn.row_factory = sqlite3.Row | |
| cur = conn.cursor() | |
| try: | |
| cur.execute("SELECT cas, name FROM aroma_chemicals") | |
| for row in cur.fetchall(): | |
| cas = row["cas"] | |
| if not cas: | |
| continue | |
| key = row["name"] | |
| if key: | |
| self.registry[key.lower()] = cas | |
| finally: | |
| conn.close() | |
| def _build_alias_index(self) -> None: | |
| for name, cas in TRADE_TO_CAS.items(): | |
| self.aliases[name.lower()] = cas | |
| # Also index stripped version without "oil" | |
| simple = name.lower().replace(" oil", "").strip() | |
| if simple and simple != name.lower(): | |
| self.aliases.setdefault(simple, cas) | |
| def resolve(self, name: str, threshold: float = 80.0) -> str | None: | |
| """Return a CAS number for a trade name, or None if unresolved.""" | |
| key = name.lower().strip().strip(".") | |
| if key in self.aliases: | |
| return self.aliases[key] or None | |
| if key in self.registry: | |
| return self.registry[key] | |
| # Fuzzy match against registry keys | |
| match = process.extractOne(key, self.registry.keys(), scorer=fuzz.token_sort_ratio) | |
| if match and match[1] >= threshold: | |
| return self.registry[match[0]] | |
| # Fuzzy match against aliases | |
| match = process.extractOne(key, self.aliases.keys(), scorer=fuzz.token_sort_ratio) | |
| if match and match[1] >= threshold: | |
| return self.aliases[match[0]] or None | |
| return None | |
| def clean_literature_formula(raw: dict[str, Any]) -> dict[str, Any] | None: | |
| """Convert a raw parsed formula into a normalized literature formula entry.""" | |
| resolver = TradeNameResolver() | |
| components: list[dict[str, Any]] = [] | |
| for c in raw.get("components", []): | |
| name = c["name"] | |
| cas = resolver.resolve(name) | |
| if cas: | |
| components.append({"cas": cas, "amount": c["amount"], "type": "pure"}) | |
| else: | |
| components.append({"name": name, "amount": c["amount"], "type": "unknown"}) | |
| if all(c["type"] == "unknown" for c in components): | |
| return None | |
| total = sum(c["amount"] for c in components) | |
| if total <= 0: | |
| return None | |
| normalized = [] | |
| for c in components: | |
| entry = dict(c) | |
| entry["amount"] = round(c["amount"] / total, 6) | |
| if entry["type"] == "unknown": | |
| entry["type"] = "sub_base" # treat unresolved trade names as accord sub-bases | |
| normalized.append(entry) | |
| return { | |
| "formula_id": raw["formula_id"], | |
| "name": raw["name"], | |
| "source": raw.get("source", ""), | |
| "page": raw.get("page", 0), | |
| "raw_basis_sum": total, | |
| "components": normalized, | |
| } | |
| if __name__ == "__main__": | |
| import json, argparse | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--input", required=True) | |
| parser.add_argument("--output", required=True) | |
| args = parser.parse_args() | |
| in_path = Path(args.input) | |
| out_path = Path(args.output) | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | |
| raw_formulas = [json.loads(line) for line in in_path.read_text().strip().splitlines()] | |
| clean = [f for f in (clean_literature_formula(r) for r in raw_formulas) if f] | |
| with out_path.open("w", encoding="utf-8") as f: | |
| for entry in clean: | |
| f.write(json.dumps(entry, ensure_ascii=False) + "\n") | |
| print(f"Resolved {len(clean)}/{len(raw_formulas)} formulas") | |