column_mapper.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from pathlib import Path
  5. _SYNONYMS_PATH = Path(__file__).parent / "column_synonyms.json"
  6. # Columns where "Comment" maps to part_number instead of value when a
  7. # richer value column is also present.
  8. _VALUE_COLS = {"value", "description", "name", "part name", "component value", "part value"}
  9. _COMMENT_COL = "comment"
  10. def _load_synonyms() -> dict[str, list[str]]:
  11. with open(_SYNONYMS_PATH, encoding="utf-8") as f:
  12. return json.load(f)
  13. def _save_synonyms(synonyms: dict[str, list[str]]) -> None:
  14. with open(_SYNONYMS_PATH, "w", encoding="utf-8") as f:
  15. json.dump(synonyms, f, indent=2, ensure_ascii=False)
  16. def map_columns(raw_rows: list[dict[str, str]]) -> list[dict[str, str]]:
  17. if not raw_rows:
  18. return []
  19. raw_keys = list(raw_rows[0].keys())
  20. synonyms = _load_synonyms()
  21. mapping = _heuristic_map(raw_keys, synonyms)
  22. mapping = _disambiguate_comment(raw_keys, mapping, raw_rows)
  23. missing_required = [c for c in ("value",) if c not in mapping]
  24. if missing_required:
  25. ai_result = _ai_map(raw_keys, missing_required, synonyms)
  26. mapping.update(ai_result)
  27. return [_remap_row(row, mapping) for row in raw_rows]
  28. def _normalize_key(s: str) -> str:
  29. return re.sub(r"\s+", " ", s.lower().strip())
  30. def _heuristic_map(raw_keys: list[str], synonyms: dict[str, list[str]]) -> dict[str, str]:
  31. # Build normalized lookup: normalized_key → original_key
  32. norm_to_orig = {_normalize_key(k): k for k in raw_keys}
  33. mapping: dict[str, str] = {}
  34. # Iterate synonyms in priority order (JSON list order) for each canonical
  35. for canonical, syn_list in synonyms.items():
  36. if canonical in mapping:
  37. continue
  38. for syn in syn_list:
  39. norm_syn = _normalize_key(syn)
  40. if norm_syn in norm_to_orig:
  41. mapping[canonical] = norm_to_orig[norm_syn]
  42. break
  43. return mapping
  44. _PASSIVE_VALUE_RE = re.compile(
  45. r"^\s*[\d\.]+\s*[pnuµmkKMG]?[FfHhRrΩ]?\s*$|"
  46. r"^\s*[\d\.]+\s*[kKMG]\s*$|"
  47. r"^\s*[\d\.]+\s*[pnuµ][fF]\s*$",
  48. re.IGNORECASE,
  49. )
  50. def _comment_looks_like_value(raw_rows: list[dict[str, str]], comment_key: str) -> bool:
  51. """Return True if Comment column values look like passive component values, not MPNs."""
  52. samples = [r.get(comment_key, "") for r in raw_rows[:10] if r.get(comment_key, "").strip()]
  53. if not samples:
  54. return False
  55. matches = sum(1 for s in samples if _PASSIVE_VALUE_RE.match(s))
  56. return matches / len(samples) > 0.4
  57. def _disambiguate_comment(
  58. raw_keys: list[str],
  59. mapping: dict[str, str],
  60. raw_rows: list[dict[str, str]],
  61. ) -> dict[str, str]:
  62. """
  63. If BOM has both Comment and a richer value column (Value/Description),
  64. treat Comment as part_number — but only if Comment doesn't look like passive values.
  65. """
  66. lower_keys = {k.lower(): k for k in raw_keys}
  67. has_comment = _COMMENT_COL in lower_keys
  68. has_value_col = any(k in lower_keys for k in _VALUE_COLS)
  69. if not (has_comment and has_value_col):
  70. return mapping
  71. original_comment_key = lower_keys[_COMMENT_COL]
  72. # Don't remap if Comment values look like passive component values
  73. if _comment_looks_like_value(raw_rows, original_comment_key):
  74. return mapping
  75. if "part_number" not in mapping:
  76. mapping["part_number"] = original_comment_key
  77. if mapping.get("value") == original_comment_key:
  78. for col_name in _VALUE_COLS:
  79. if col_name in lower_keys and lower_keys[col_name] != original_comment_key:
  80. mapping["value"] = lower_keys[col_name]
  81. break
  82. return mapping
  83. def _ai_map(
  84. raw_keys: list[str],
  85. missing: list[str],
  86. synonyms: dict[str, list[str]],
  87. ) -> dict[str, str]:
  88. import anthropic
  89. client = anthropic.Anthropic()
  90. prompt = (
  91. f"Map these BOM spreadsheet column names to canonical fields.\n"
  92. f"Columns present: {raw_keys}\n"
  93. f"Need mappings for: {missing}\n"
  94. f"Canonical fields:\n"
  95. f" value — component value or description (e.g. '100nF', '10k', 'STM32F405')\n"
  96. f" designators — reference designators (e.g. 'C1, C2', 'R5')\n"
  97. f" footprint — PCB footprint or package\n"
  98. f" quantity — how many of this component\n"
  99. f" part_number — manufacturer part number / MPN\n"
  100. f" manufacturer — manufacturer name\n\n"
  101. f"Return JSON only, no explanation: "
  102. f'{{\"canonical_field\": \"exact_original_column_name\", ...}}\n'
  103. f"Only include fields you are confident about."
  104. )
  105. msg = client.messages.create(
  106. model="claude-haiku-4-5-20251001",
  107. max_tokens=300,
  108. messages=[{"role": "user", "content": prompt}],
  109. )
  110. text = msg.content[0].text
  111. match = re.search(r"\{.*\}", text, re.DOTALL)
  112. if not match:
  113. return {}
  114. try:
  115. ai_mapping: dict[str, str] = json.loads(match.group())
  116. except json.JSONDecodeError:
  117. return {}
  118. # persist new synonyms so we avoid future API calls
  119. updated = False
  120. for canonical, raw_col in ai_mapping.items():
  121. if canonical not in synonyms:
  122. continue
  123. normalized = raw_col.lower().strip()
  124. existing = [s.lower() for s in synonyms[canonical]]
  125. if normalized not in existing:
  126. synonyms[canonical].append(raw_col.lower().strip())
  127. updated = True
  128. if updated:
  129. _save_synonyms(synonyms)
  130. return ai_mapping
  131. def _remap_row(row: dict[str, str], mapping: dict[str, str]) -> dict[str, str]:
  132. mapped_originals = set(mapping.values())
  133. result: dict[str, str] = {}
  134. for canonical, original in mapping.items():
  135. result[canonical] = row.get(original, "")
  136. # pass through unmapped columns with _raw_ prefix
  137. for k, v in row.items():
  138. if k not in mapped_originals:
  139. result[f"_raw_{k}"] = v
  140. return result