Skip to content

Commit 0b073ff

Browse files
committed
fix: preserve XLSX workbook fidelity
1 parent ed90cde commit 0b073ff

11 files changed

Lines changed: 1979 additions & 66 deletions

File tree

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: curate-aroma-data
3-
description: Use AromaNexus to validate, normalize, enrich, and export flavor, odor, aroma, and chemical-sensory datasets with source-level provenance. Use for XLSX, CSV, or TSV workbooks containing CAS numbers, compound names, calculated retention indices, sensory descriptors, odor thresholds, or olfactory-receptor evidence; for selecting among NIST, PubChem, Pyrfume, M2OR, MFFI, and the permission-gated ChemicalBook compatibility connector; and for verifying analysis-ready outputs. Do not use to bypass access controls, relicense source data, or make biomedical or machine-learning claims unsupported by the exported evidence.
3+
description: Use AromaNexus to validate, normalize, enrich, and export flavor, odor, aroma, and chemical-sensory datasets with source-level provenance. Use for XLSX, CSV, or TSV files containing CAS numbers, compound names, calculated retention indices, sensory descriptors, odor thresholds, or olfactory-receptor evidence; for selecting among NIST, PubChem, Pyrfume, M2OR, MFFI, and the permission-gated ChemicalBook compatibility connector; and for verifying analysis-ready outputs. Do not use to bypass access controls, relicense source data, or make biomedical or machine-learning claims unsupported by the exported evidence.
44
---
55

66
# Curate Aroma Data
@@ -10,8 +10,9 @@ Build traceable compound tables through the repository's `aromanexus` CLI. Keep
1010
## Workflow
1111

1212
1. Inspect the input without modifying it.
13-
- Run `python .agents/skills/curate-aroma-data/scripts/inspect_workbook.py INPUT` from the repository root.
14-
- Confirm the row count, exact column names, identifier quality, duplicates, and formula-like cells.
13+
- For XLSX, run `python .agents/skills/curate-aroma-data/scripts/inspect_workbook.py INPUT --sheet "SHEET"` from the repository root. Omit `--sheet` only when the first worksheet is the intended target.
14+
- For CSV or TSV, run the inspector without `--sheet`; flat files have no worksheets.
15+
- Confirm the worksheet order and exact target when applicable, row count, exact column names, identifier quality, duplicates, source formulas, workbook properties, per-sheet content digests, styles, dimensions, and reported features.
1516
- 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.
1617
2. Choose the smallest provider set that supplies the requested fields.
1718
- Read [references/provider-matrix.md](references/provider-matrix.md) before any network or browser operation.
@@ -20,8 +21,9 @@ Build traceable compound tables through the repository's `aromanexus` CLI. Keep
2021
- Use Pyrfume only for explicitly selected archives after reviewing each manifest note.
2122
- Use M2OR only when receptor bioassay evidence is relevant; label species and assay scope.
2223
3. Preview the operation.
23-
- State the input, new output path, selected provider, expected columns, skip patterns, approximate request count, cache behavior, and material access caveats.
24-
- Write a sibling output by default. Do not pass `--force` or overwrite the input unless the user explicitly requests that exact replacement.
24+
- State the input, selected worksheet for XLSX, new output path, selected provider, expected columns, skip patterns, approximate request count, cache behavior, and material access caveats.
25+
- Write a sibling output by default. Never reuse the input path as the output path; `--force` is only for a separate existing destination.
26+
- Keep XLSX input and output when worksheet formulas, formatting, or other workbook content must survive; CSV/TSV output is a flat export.
2527
4. Run one focused command.
2628
- Identity and odor metadata: `aromanexus pubchem INPUT --identifier-column "CAS Number"`
2729
- Name lookup with dataset-specific structural rows: `aromanexus pubchem INPUT --identifier-column "Name" --skip-pattern '^C\d+$'`
@@ -30,9 +32,11 @@ Build traceable compound tables through the repository's `aromanexus` CLI. Keep
3032
- Curated descriptors: `aromanexus pyrfume INPUT --archives aromadb,superscent`
3133
- Receptor evidence: `aromanexus m2or INPUT --cas-column "CAS Number"`
3234
- Source inventory: `aromanexus sources`
35+
- For XLSX only, append `--sheet "SHEET"` to any table command when the target is not the first worksheet. Never pass `--sheet` for CSV or TSV.
3336
5. Verify the result.
34-
- Re-run the inspection script on the output.
37+
- Re-run the inspection script on the same worksheet for XLSX, or without `--sheet` for CSV/TSV.
3538
- Confirm identical row order and row count, expected new fields, typed status counts, source URL, retrieval time, version, and license/access fields.
39+
- For XLSX output, also compare worksheet order and names, per-sheet content digests, workbook properties, non-target-sheet content, untargeted source formulas and cached values, styles, dimensions, and reported workbook features.
3640
- Treat `PubChem Status` as provider execution state, not proof of a uniquely resolved CAS. Check `PubChem CAS Resolution`, candidate count, and `Resolved CAS` separately.
3741
- Leave `multiple` and `missing` CAS resolutions unresolved; retain all candidates and route only the affected rows to a targeted fallback source or manual review.
3842
- Treat `http_error`, `network_error`, `parse_error`, `missing_data`, `data_error`, `partial`, `blocked`, and `skipped` separately from `not_found`.
@@ -46,5 +50,7 @@ Build traceable compound tables through the repository's `aromanexus` CLI. Keep
4650
- Do not run ChemicalBook automation unless the user confirms documented permission; its current robots policy excludes the legacy routes.
4751
- Do not describe the toolkit as AI-powered. Say that structured exports can support downstream statistics, cheminformatics, or machine-learning experiments.
4852
- Do not bundle or republish downloaded Pyrfume or M2OR data in the repository.
49-
- Preserve remote text as literal spreadsheet cells to prevent formula execution.
53+
- Preserve newly fetched remote text as literal spreadsheet cells to prevent formula execution. Preserve legitimate source formulas outside cells explicitly targeted by an output field.
54+
- Preserve merged cells outside the selected tabular rectangle; stop before provider calls when a merge intersects that rectangle.
55+
- Stop before provider calls if XLSX preflight reports a known unsafe feature or any OOXML package part that the in-memory trial write would discard.
5056
- 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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,9 @@ After every run, confirm:
5858
5. Remote strings beginning with `=`, `+`, `-`, or `@` are stored as literal text.
5959
6. Partial outputs are reported separately if a run is interrupted.
6060
7. `multiple`, `missing`, `not_evaluated`, and `skipped` PubChem CAS resolutions never contain an automatic `Resolved CAS`.
61+
8. XLSX worksheet order and names match the input, and supported non-target worksheet content and features are unchanged.
62+
9. Source formulas and cached results outside explicitly targeted output cells, plus styles, dimensions, freeze panes, filters, tables, data validation, conditional formatting, and workbook properties, remain present where applicable.
63+
10. Merged cells outside the selected tabular rectangle remain present; a merge intersecting that rectangle is rejected before provider access.
64+
11. The target worksheet used for post-run inspection is the same exact worksheet selected for enrichment, and per-sheet content digests are compared.
65+
66+
For XLSX input, the default target is the first worksheet in workbook order. Pass `--sheet "Name"` to both the command and inspection helper when another worksheet is intended. Never pass `--sheet` for CSV or TSV; flat output cannot satisfy workbook-level preservation checks. XLSX preflight rejects known unsafe features and any OOXML package part dropped by its in-memory trial write. Excel's optional calculation chain may be removed and rebuilt by spreadsheet software.

.agents/skills/curate-aroma-data/scripts/inspect_workbook.py

Lines changed: 133 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,106 @@
33
from __future__ import annotations
44

55
import argparse
6+
import hashlib
67
import json
78
import sys
89
from pathlib import Path
910

11+
from openpyxl import load_workbook
12+
1013
REPO_ROOT = Path(__file__).resolve().parents[4]
1114
if str(REPO_ROOT) not in sys.path:
1215
sys.path.insert(0, str(REPO_ROOT))
1316

14-
from aromanexus.excel_io import FORMULA_PREFIXES, read_table # noqa: E402
17+
from aromanexus.excel_io import ( # noqa: E402
18+
FORMULA_PREFIXES,
19+
read_table,
20+
workbook_sheet_names,
21+
)
1522
from aromanexus.identifiers import is_valid_cas, normalize_cas # noqa: E402
1623

1724

18-
def inspect(path: Path, cas_column: str | None = None) -> dict[str, object]:
19-
frame = read_table(path)
25+
def _stable_cell_value(value: object) -> str | None:
26+
if value is None:
27+
return None
28+
return value.isoformat() if hasattr(value, "isoformat") else str(value)
29+
30+
31+
def _sheet_summary(worksheet, cached_worksheet) -> dict[str, object]:
32+
digest = hashlib.sha256()
33+
formula_cache_digest = hashlib.sha256()
34+
formula_cells = 0
35+
cached_formula_results = 0
36+
styled_cells = 0
37+
nonempty_cells = 0
38+
for row in worksheet.iter_rows():
39+
for cell in row:
40+
if cell.has_style:
41+
styled_cells += 1
42+
if cell.value is None:
43+
continue
44+
nonempty_cells += 1
45+
formula_cells += int(cell.data_type == "f")
46+
value = _stable_cell_value(cell.value)
47+
record = json.dumps(
48+
[cell.coordinate, cell.data_type, value],
49+
ensure_ascii=False,
50+
separators=(",", ":"),
51+
)
52+
digest.update(record.encode("utf-8"))
53+
digest.update(b"\n")
54+
if cell.data_type == "f":
55+
cached_cell = cached_worksheet[cell.coordinate]
56+
cached_value = _stable_cell_value(cached_cell.value)
57+
cached_formula_results += int(cached_value is not None)
58+
cached_record = json.dumps(
59+
[cell.coordinate, cached_cell.data_type, cached_value],
60+
ensure_ascii=False,
61+
separators=(",", ":"),
62+
)
63+
formula_cache_digest.update(cached_record.encode("utf-8"))
64+
formula_cache_digest.update(b"\n")
65+
return {
66+
"max_row": worksheet.max_row,
67+
"max_column": worksheet.max_column,
68+
"nonempty_cells": nonempty_cells,
69+
"formula_cells": formula_cells,
70+
"cached_formula_results": cached_formula_results,
71+
"formula_cache_sha256": formula_cache_digest.hexdigest(),
72+
"styled_cells": styled_cells,
73+
"content_sha256": digest.hexdigest(),
74+
"row_heights": {
75+
str(index): dimension.height
76+
for index, dimension in worksheet.row_dimensions.items()
77+
if dimension.height is not None
78+
},
79+
"column_widths": {
80+
name: dimension.width
81+
for name, dimension in worksheet.column_dimensions.items()
82+
if dimension.width is not None
83+
},
84+
"freeze_panes": str(worksheet.freeze_panes or ""),
85+
"auto_filter": str(worksheet.auto_filter.ref or ""),
86+
"merged_ranges": [str(item) for item in worksheet.merged_cells.ranges],
87+
"tables": list(worksheet.tables),
88+
"data_validations": len(worksheet.data_validations.dataValidation),
89+
"conditional_formatting_ranges": len(worksheet.conditional_formatting),
90+
}
91+
92+
93+
def inspect(
94+
path: Path,
95+
cas_column: str | None = None,
96+
sheet_name: str | None = None,
97+
) -> dict[str, object]:
98+
frame = read_table(path, sheet_name=sheet_name)
2099
detected_cas = cas_column or next(
21100
(name for name in ("CAS Number", "CAS", "cas", "cas_number") if name in frame.columns),
22101
None,
23102
)
103+
if detected_cas is not None and detected_cas not in frame.columns:
104+
available = ", ".join(map(str, frame.columns))
105+
raise ValueError(f"CAS column {detected_cas!r} was not found. Available: {available}")
24106
formula_like = 0
25107
for column in frame.columns:
26108
formula_like += int(
@@ -39,6 +121,42 @@ def inspect(path: Path, cas_column: str | None = None) -> dict[str, object]:
39121
for column in frame.columns
40122
},
41123
}
124+
if path.suffix.lower() == ".xlsx":
125+
sheets = workbook_sheet_names(path)
126+
selected_sheet = sheet_name or sheets[0]
127+
workbook = load_workbook(
128+
path,
129+
read_only=False,
130+
data_only=False,
131+
keep_links=True,
132+
rich_text=True,
133+
)
134+
cached_workbook = load_workbook(
135+
path,
136+
read_only=False,
137+
data_only=True,
138+
keep_links=True,
139+
rich_text=True,
140+
)
141+
try:
142+
sheet_summaries = {
143+
worksheet.title: _sheet_summary(
144+
worksheet,
145+
cached_workbook[worksheet.title],
146+
)
147+
for worksheet in workbook.worksheets
148+
}
149+
report["workbook"] = {
150+
"title": workbook.properties.title or "",
151+
"creator": workbook.properties.creator or "",
152+
"sheets": sheets,
153+
"selected_sheet": selected_sheet,
154+
"sheet_summaries": sheet_summaries,
155+
"selected_sheet_summary": sheet_summaries[selected_sheet],
156+
}
157+
finally:
158+
workbook.close()
159+
cached_workbook.close()
42160
if detected_cas:
43161
normalized = frame[detected_cas].map(normalize_cas)
44162
valid_mask = normalized.map(is_valid_cas)
@@ -61,8 +179,19 @@ def main() -> int:
61179
parser = argparse.ArgumentParser(description=__doc__)
62180
parser.add_argument("input", type=Path)
63181
parser.add_argument("--cas-column")
182+
parser.add_argument("--sheet", help="XLSX worksheet to inspect (defaults to the first)")
64183
args = parser.parse_args()
65-
print(json.dumps(inspect(args.input, args.cas_column), ensure_ascii=False, indent=2))
184+
try:
185+
print(
186+
json.dumps(
187+
inspect(args.input, args.cas_column, args.sheet),
188+
ensure_ascii=False,
189+
indent=2,
190+
)
191+
)
192+
except (FileNotFoundError, ValueError) as exc:
193+
print(f"Error: {exc}", file=sys.stderr)
194+
return 2
66195
return 0
67196

68197

0 commit comments

Comments
 (0)