category_resolver.py 3.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from dataclasses import dataclass
  5. from pathlib import Path
  6. from bom_assistant.ai.client import complete
  7. from bom_assistant.session.models import BomRow
  8. @dataclass
  9. class ResolvedCategory:
  10. name: str
  11. id: int
  12. def resolve_category(
  13. row: BomRow,
  14. subcats: list[dict],
  15. supplier: str,
  16. cache_path: Path,
  17. ) -> ResolvedCategory | None:
  18. """Map a BomRow to a supplier subcategory using LLM. Cache hit → instant; miss → LLM call → cached."""
  19. cache = _load(cache_path)
  20. key = _cache_key(row)
  21. if key in cache:
  22. e = cache[key]
  23. return ResolvedCategory(e["name"], e["id"])
  24. if not subcats:
  25. return None
  26. result = _ai_resolve(row, subcats, supplier)
  27. if result:
  28. cache[key] = {"name": result.name, "id": result.id}
  29. _save(cache, cache_path)
  30. return result
  31. def _cache_key(row: BomRow) -> str:
  32. p = row.normalized_params
  33. return f"{row.category.value}|{p.package or ''}|{p.value_str or row.raw_value}"
  34. def _ai_resolve(row: BomRow, subcats: list[dict], supplier: str) -> ResolvedCategory | None:
  35. p = row.normalized_params
  36. lines: list[str] = [f" type: {row.category.value}"]
  37. if p.value_str:
  38. lines.append(f" value: {p.value_str}")
  39. if p.package:
  40. lines.append(f" package: {p.package}")
  41. if p.voltage_rating is not None:
  42. lines.append(f" voltage_rating: {p.voltage_rating}V")
  43. if p.current_rating is not None:
  44. lines.append(f" current_rating: {p.current_rating}A")
  45. if p.part_number:
  46. lines.append(f" part_number: {p.part_number}")
  47. if row.raw_value and row.raw_value != p.value_str:
  48. lines.append(f" raw_value: {row.raw_value!r}")
  49. subcat_lines = "\n".join(f" {s['name']}" for s in subcats)
  50. prompt = (
  51. f"You are an electronics sourcing expert. Pick the best {supplier.upper()} subcategory for this component.\n\n"
  52. "Component:\n"
  53. + "\n".join(lines)
  54. + "\n\nAvailable subcategories:\n"
  55. + subcat_lines
  56. + '\n\nReply ONLY with JSON: {"subcategory": "<exact name from list above>"}'
  57. )
  58. try:
  59. text = complete(prompt, max_tokens=100)
  60. m = re.search(r'"subcategory"\s*:\s*"([^"]+)"', text)
  61. if not m:
  62. return None
  63. return _lookup(m.group(1).strip(), subcats)
  64. except Exception:
  65. return None
  66. def _lookup(chosen: str, subcats: list[dict]) -> ResolvedCategory | None:
  67. cl = chosen.lower()
  68. for s in subcats:
  69. if s["name"].lower() == cl:
  70. return ResolvedCategory(s["name"], s["id"])
  71. for s in subcats:
  72. if cl in s["name"].lower() or s["name"].lower() in cl:
  73. return ResolvedCategory(s["name"], s["id"])
  74. return None
  75. def _load(path: Path) -> dict:
  76. return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
  77. def _save(cache: dict, path: Path) -> None:
  78. path.write_text(json.dumps(cache, indent=2, ensure_ascii=False), encoding="utf-8")