pino-source-code / src /pino /trade_name_resolver.py
mattbitzesty's picture
feat(literature): add trade-name resolver, Poucher/Arctander parsers, and PyMuPDF extraction scripts
0ebf67e unverified
Raw
History Blame Contribute Delete
14.4 kB
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")