File size: 14,390 Bytes
0ebf67e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
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")