Skip to content

Commit ed90cde

Browse files
authored
Merge pull request #7 from rastagan-git/agent/conservative-pubchem-curation
fix: make PubChem CAS resolution conservative
2 parents f258ddd + 198c140 commit ed90cde

10 files changed

Lines changed: 295 additions & 7 deletions

File tree

.agents/skills/curate-aroma-data/SKILL.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,19 @@ Build traceable compound tables through the repository's `aromanexus` CLI. Keep
1212
1. Inspect the input without modifying it.
1313
- Run `python .agents/skills/curate-aroma-data/scripts/inspect_workbook.py INPUT` from the repository root.
1414
- Confirm the row count, exact column names, identifier quality, duplicates, and formula-like cells.
15+
- Identify section labels, headers, totals, and other structural rows before provider calls. Define an explicit dataset-specific skip rule; do not assume that text such as `C6` is globally non-chemical.
1516
2. Choose the smallest provider set that supplies the requested fields.
1617
- Read [references/provider-matrix.md](references/provider-matrix.md) before any network or browser operation.
1718
- Prefer PubChem for canonical identity and sourced odor annotations.
1819
- Use NIST only for the existing retention-index or name-resolution workflows.
1920
- Use Pyrfume only for explicitly selected archives after reviewing each manifest note.
2021
- Use M2OR only when receptor bioassay evidence is relevant; label species and assay scope.
2122
3. Preview the operation.
22-
- State the input, new output path, selected provider, expected columns, approximate request count, cache behavior, and material access caveats.
23+
- State the input, new output path, selected provider, expected columns, skip patterns, approximate request count, cache behavior, and material access caveats.
2324
- Write a sibling output by default. Do not pass `--force` or overwrite the input unless the user explicitly requests that exact replacement.
2425
4. Run one focused command.
2526
- Identity and odor metadata: `aromanexus pubchem INPUT --identifier-column "CAS Number"`
27+
- Name lookup with dataset-specific structural rows: `aromanexus pubchem INPUT --identifier-column "Name" --skip-pattern '^C\d+$'`
2628
- Retention indices: `aromanexus nist-ri INPUT --cas-column "CAS Number" --calculated-ri-column "Calculated RI"`
2729
- Names to CAS: `aromanexus resolve-cas INPUT --name-column "Name"`
2830
- Curated descriptors: `aromanexus pyrfume INPUT --archives aromadb,superscent`
@@ -31,6 +33,8 @@ Build traceable compound tables through the repository's `aromanexus` CLI. Keep
3133
5. Verify the result.
3234
- Re-run the inspection script on the output.
3335
- Confirm identical row order and row count, expected new fields, typed status counts, source URL, retrieval time, version, and license/access fields.
36+
- Treat `PubChem Status` as provider execution state, not proof of a uniquely resolved CAS. Check `PubChem CAS Resolution`, candidate count, and `Resolved CAS` separately.
37+
- Leave `multiple` and `missing` CAS resolutions unresolved; retain all candidates and route only the affected rows to a targeted fallback source or manual review.
3438
- Treat `http_error`, `network_error`, `parse_error`, `missing_data`, `data_error`, `partial`, `blocked`, and `skipped` separately from `not_found`.
3539
- Consult [references/output-schema.md](references/output-schema.md) when reconciling columns or statuses.
3640
6. Report the output path, provider versions, status counts, partial failures, and any source terms the user must still review.
@@ -43,3 +47,4 @@ Build traceable compound tables through the repository's `aromanexus` CLI. Keep
4347
- Do not describe the toolkit as AI-powered. Say that structured exports can support downstream statistics, cheminformatics, or machine-learning experiments.
4448
- Do not bundle or republish downloaded Pyrfume or M2OR data in the repository.
4549
- Preserve remote text as literal spreadsheet cells to prevent formula execution.
50+
- Never select the first PubChem CAS candidate merely because the provider status is `ok`.

.agents/skills/curate-aroma-data/references/output-schema.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@
77
- Keep CAS as an external identifier and validate its checksum before exact-match providers.
88
- Never silently pick an ambiguous name result.
99

10+
## PubChem CAS resolution
11+
12+
Keep provider execution and CAS curation in separate columns:
13+
14+
- `PubChem Status`: whether the provider request and record parsing succeeded.
15+
- `PubChem CAS Numbers`: every checksum-valid candidate retained for review.
16+
- `PubChem CAS Candidate Count`: the number of distinct valid candidates.
17+
- `PubChem CAS Resolution`: the conservative resolution decision.
18+
- `Resolved CAS`: populate only for `query_confirmed` or `unique`.
19+
20+
Interpret `PubChem CAS Resolution` as follows:
21+
22+
- `query_confirmed`: the input was a checksum-valid CAS and PubChem resolved its record.
23+
- `unique`: a successful name lookup returned exactly one valid CAS candidate.
24+
- `multiple`: more than one valid candidate remains; keep `Resolved CAS` empty.
25+
- `missing`: a successful record returned no valid CAS candidate; keep `Resolved CAS` empty.
26+
- `not_evaluated`: provider failure prevented a defensible CAS decision.
27+
- `skipped`: an explicit user-supplied rule excluded the row before lookup.
28+
1029
## Status values
1130

1231
- `ok`: a provider returned a parsed record.
@@ -20,7 +39,7 @@
2039
- `data_error`: every selected archive failed to load; inspect the per-archive diagnostic message.
2140
- `partial`: at least one selected source succeeded and at least one failed, or a provider returned usable data with warnings.
2241
- `blocked`: access policy or missing permission prevented the request.
23-
- `skipped`: a user deliberately skipped an interactive record.
42+
- `skipped`: a user deliberately excluded a record through an interactive choice or explicit skip rule.
2443

2544
Do not merge an access, transport, HTTP, snapshot, parse, or partial failure into `not_found`.
2645

@@ -38,3 +57,4 @@ After every run, confirm:
3857
4. Every processed row has a typed status.
3958
5. Remote strings beginning with `=`, `+`, `-`, or `@` are stored as literal text.
4059
6. Partial outputs are reported separately if a run is interrupted.
60+
7. `multiple`, `missing`, `not_evaluated`, and `skipped` PubChem CAS resolutions never contain an automatic `Resolved CAS`.

README-CN.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ CLI 支持 `.xlsx`、`.csv` 和 `.tsv`。列名均可修改;下面使用默认
6666
# 规范化身份、理化性质、同义词、CAS 标识符与带来源的气味文本
6767
aromanexus pubchem compounds.xlsx --identifier-column "CAS Number"
6868

69+
# 在名称查询前跳过当前数据集中的结构标签
70+
aromanexus pubchem compounds.xlsx --identifier-column "Name" --skip-pattern '^C\d+$'
71+
6972
# 在 NIST 中寻找最接近实验计算值的保留指数
7073
aromanexus nist-ri data.xlsx \
7174
--cas-column "CAS Number" \
@@ -112,7 +115,7 @@ aromanexus --cache-dir .cache/aromanexus --timeout 30 pubchem compounds.xlsx
112115

113116
所有表格命令默认在输入文件旁生成新文件,保留原有行序和列,再添加数据源字段。例如,PubChem 会将 `compounds.xlsx` 输出为 `compounds_pubchem.xlsx`
114117

115-
默认来源记录包括数据源状态、来源 URL、获取时间、是否命中缓存、固定版本、许可 URL 与诊断信息。只有在确实需要旧版形状时才使用 `--no-provenance`
118+
默认来源记录包括数据源状态、来源 URL、获取时间、是否命中缓存、固定版本、许可 URL 与诊断信息。PubChem 会单独报告 CAS 解析状态;仅当输入 CAS 得到确认,或只剩一个校验有效的候选时,才填入 `Resolved CAS`。多个或缺失候选会保持未解析。只有在确实需要旧版形状时才使用 `--no-provenance`
116119

117120
```bash
118121
# 明确指定输出位置

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ The CLI accepts `.xlsx`, `.csv`, and `.tsv` tables. Column names are configurabl
6666
# Canonical identity, properties, synonyms, CAS identifiers, and sourced odor text
6767
aromanexus pubchem compounds.xlsx --identifier-column "CAS Number"
6868

69+
# Skip dataset-specific structural labels before a name lookup
70+
aromanexus pubchem compounds.xlsx --identifier-column "Name" --skip-pattern '^C\d+$'
71+
6972
# Closest NIST RI to an experimentally calculated RI
7073
aromanexus nist-ri data.xlsx \
7174
--cas-column "CAS Number" \
@@ -112,7 +115,7 @@ aromanexus --cache-dir .cache/aromanexus --timeout 30 pubchem compounds.xlsx
112115

113116
Every table command writes a sibling file by default, keeps the original row order and columns, and adds provider fields. For example, `compounds.xlsx` becomes `compounds_pubchem.xlsx` after a PubChem run.
114117

115-
By default, provenance columns include provider status, source URL, retrieval timestamp, cache hit, pinned version, license URL, and a diagnostic message. Use `--no-provenance` only for legacy-shaped output.
118+
By default, provenance columns include provider status, source URL, retrieval timestamp, cache hit, pinned version, license URL, and a diagnostic message. PubChem reports CAS resolution separately and populates `Resolved CAS` only when the input is a confirmed CAS or exactly one checksum-valid candidate remains. Multiple or missing candidates stay unresolved. Use `--no-provenance` only for legacy-shaped output.
116119

117120
```bash
118121
# Choose an output explicitly

aromanexus/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
from aromanexus.models import LookupResult
44

55
__all__ = ["LookupResult"]
6-
__version__ = "0.3.0"
6+
__version__ = "0.3.1"

aromanexus/cli.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ def build_parser() -> argparse.ArgumentParser:
9999
)
100100
_add_table_arguments(pubchem)
101101
pubchem.add_argument("--identifier-column", default="CAS Number")
102+
pubchem.add_argument(
103+
"--skip-pattern",
104+
action="append",
105+
default=[],
106+
metavar="REGEX",
107+
help="Skip identifiers matching this regex before lookup; repeat for multiple patterns",
108+
)
109+
pubchem.add_argument(
110+
"--resolved-cas-column",
111+
default="Resolved CAS",
112+
help="Output column populated only for query-confirmed or unique CAS resolution",
113+
)
102114
pubchem.add_argument("--no-odor", action="store_true", help="Skip PUG-View odor annotations")
103115
pubchem.set_defaults(handler=_handle_pubchem)
104116

@@ -199,6 +211,8 @@ def _handle_pubchem(args: argparse.Namespace) -> RunSummary:
199211
args.input,
200212
client,
201213
identifier_column=args.identifier_column,
214+
resolved_cas_column=args.resolved_cas_column,
215+
skip_patterns=args.skip_pattern,
202216
include_odor=not args.no_odor,
203217
**_common_kwargs(args),
204218
)

aromanexus/workflows.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import re
67
import time
78
from collections import Counter
89
from collections.abc import Callable, Iterable
@@ -18,6 +19,7 @@
1819
require_columns,
1920
write_table,
2021
)
22+
from aromanexus.identifiers import clean_text, is_valid_cas, normalize_cas
2123
from aromanexus.models import LookupResult
2224
from aromanexus.sources.chemicalbook import ManualVerificationRequired
2325

@@ -217,25 +219,99 @@ def run_resolve_cas(
217219
}
218220

219221

222+
def _compile_skip_patterns(patterns: Iterable[str] | str | None) -> tuple[re.Pattern[str], ...]:
223+
raw_patterns = (patterns,) if isinstance(patterns, str) else patterns or ()
224+
compiled: list[re.Pattern[str]] = []
225+
for pattern in raw_patterns:
226+
try:
227+
compiled.append(re.compile(pattern))
228+
except re.error as exc:
229+
raise ValueError(f"Invalid --skip-pattern regex {pattern!r}: {exc}") from exc
230+
return tuple(compiled)
231+
232+
233+
def _valid_cas_candidates(value: Any) -> list[str]:
234+
if isinstance(value, (list, tuple, set)):
235+
raw_candidates = value
236+
elif value is None:
237+
raw_candidates = ()
238+
else:
239+
raw_candidates = (value,)
240+
241+
candidates: list[str] = []
242+
for candidate in raw_candidates:
243+
normalized = normalize_cas(candidate)
244+
if is_valid_cas(normalized) and normalized not in candidates:
245+
candidates.append(normalized)
246+
return candidates
247+
248+
249+
def _apply_pubchem_resolution(
250+
frame: pd.DataFrame,
251+
index: Any,
252+
identifier: Any,
253+
result: LookupResult,
254+
*,
255+
resolved_cas_column: str,
256+
) -> None:
257+
candidates = _valid_cas_candidates(result.values.get("cas_numbers"))
258+
resolved_cas = ""
259+
if result.status == "skipped":
260+
resolution = "skipped"
261+
elif result.status != "ok":
262+
resolution = "not_evaluated"
263+
else:
264+
query_cas = normalize_cas(identifier)
265+
if is_valid_cas(query_cas):
266+
resolution = "query_confirmed"
267+
resolved_cas = query_cas
268+
elif len(candidates) == 1:
269+
resolution = "unique"
270+
resolved_cas = candidates[0]
271+
elif candidates:
272+
resolution = "multiple"
273+
else:
274+
resolution = "missing"
275+
276+
_set_cell(frame, index, "PubChem CAS Resolution", resolution)
277+
_set_cell(frame, index, "PubChem CAS Candidate Count", len(candidates))
278+
_set_cell(frame, index, resolved_cas_column, resolved_cas)
279+
280+
220281
def run_pubchem(
221282
input_path: str | Path,
222283
client: Any,
223284
*,
224285
output_path: str | Path | None = None,
225286
identifier_column: str = "CAS Number",
287+
resolved_cas_column: str = "Resolved CAS",
288+
skip_patterns: Iterable[str] | str | None = None,
226289
include_odor: bool = True,
227290
include_provenance: bool = True,
228291
checkpoint_every: int = 25,
229292
force: bool = False,
230293
progress: ProgressCallback = console_progress,
231294
) -> RunSummary:
295+
compiled_skip_patterns = _compile_skip_patterns(skip_patterns)
232296
frame, destination = _prepare_run(input_path, output_path, suffix="_pubchem", force=force)
233297
require_columns(frame, identifier_column)
234298
statuses: Counter[str] = Counter()
235299
total = len(frame)
236300
for current, index, row in _indices(frame):
237301
identifier = row[identifier_column]
238-
result = client.lookup(identifier, include_odor=include_odor)
302+
identifier_text = clean_text(identifier)
303+
matching_pattern = next(
304+
(pattern for pattern in compiled_skip_patterns if pattern.search(identifier_text)),
305+
None,
306+
)
307+
if matching_pattern is not None:
308+
result = LookupResult.failure(
309+
"PubChem",
310+
status="skipped",
311+
message=f"Identifier matched --skip-pattern {matching_pattern.pattern!r}",
312+
)
313+
else:
314+
result = client.lookup(identifier, include_odor=include_odor)
239315
_apply_result(
240316
frame,
241317
index,
@@ -244,6 +320,13 @@ def run_pubchem(
244320
prefix="PubChem",
245321
key_map=PUBCHEM_COLUMN_MAP,
246322
)
323+
_apply_pubchem_resolution(
324+
frame,
325+
index,
326+
identifier,
327+
result,
328+
resolved_cas_column=resolved_cas_column,
329+
)
247330
statuses[result.status] += 1
248331
progress(current, total, f"PubChem: {identifier} -> {result.status}")
249332
_checkpoint(frame, destination, current, checkpoint_every)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "aromanexus"
7-
version = "0.3.0"
7+
version = "0.3.1"
88
description = "Provenance-aware integration for odorant, flavor-molecule, and olfactory-receptor data"
99
readme = "README.md"
1010
license = "MIT"

tests/test_cli.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,39 @@ def test_missing_input_returns_user_error(capsys, tmp_path):
1313
code = main(["pubchem", str(tmp_path / "missing.xlsx")])
1414
assert code == 2
1515
assert "does not exist" in capsys.readouterr().err
16+
17+
18+
def test_pubchem_cli_forwards_repeatable_skip_patterns_and_resolved_column(monkeypatch, tmp_path):
19+
input_path = tmp_path / "input.csv"
20+
input_path.write_text("Name\nC6\n", encoding="utf-8")
21+
captured = {}
22+
23+
monkeypatch.setattr("aromanexus.sources.pubchem.PubChemClient", lambda **_kwargs: object())
24+
25+
def fake_run_pubchem(input_file, client, **kwargs):
26+
captured["input"] = input_file
27+
captured["client"] = client
28+
captured["kwargs"] = kwargs
29+
return 0
30+
31+
monkeypatch.setattr("aromanexus.cli.run_pubchem", fake_run_pubchem)
32+
33+
code = main(
34+
[
35+
"pubchem",
36+
str(input_path),
37+
"--identifier-column",
38+
"Name",
39+
"--skip-pattern",
40+
r"^C\d+$",
41+
"--skip-pattern",
42+
"^Total$",
43+
"--resolved-cas-column",
44+
"Curated CAS",
45+
]
46+
)
47+
48+
assert code == 0
49+
assert captured["input"] == input_path
50+
assert captured["kwargs"]["skip_patterns"] == [r"^C\d+$", "^Total$"]
51+
assert captured["kwargs"]["resolved_cas_column"] == "Curated CAS"

0 commit comments

Comments
 (0)