category_resolver.py 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  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. ) -> list[ResolvedCategory]:
  18. """Map a BomRow to up to 3 supplier subcategories ranked by confidence. Cache hit → instant."""
  19. cache = _load(cache_path)
  20. key = _cache_key(row)
  21. if key in cache:
  22. return _load_entry(cache[key])
  23. if not subcats:
  24. return []
  25. results = _ai_resolve(row, subcats, supplier)
  26. if results:
  27. cache[key] = [{"name": r.name, "id": r.id} for r in results]
  28. _save(cache, cache_path)
  29. return results
  30. def _load_entry(e) -> list[ResolvedCategory]:
  31. if isinstance(e, dict):
  32. return [ResolvedCategory(e["name"], e["id"])]
  33. return [ResolvedCategory(x["name"], x["id"]) for x in e]
  34. def _cache_key(row: BomRow) -> str:
  35. p = row.normalized_params
  36. return f"{row.category.value}|{p.package or ''}|{p.value_str or row.raw_value}"
  37. def _fmt_subcat(s: dict) -> str:
  38. line = f" {s['name']}"
  39. desc = s.get("description", "")
  40. examples = s.get("examples", [])
  41. if desc or examples:
  42. line += f" — {desc}"
  43. if examples:
  44. line += f" e.g. {', '.join(examples)}"
  45. return line
  46. def _ai_resolve(row: BomRow, subcats: list[dict], supplier: str) -> list[ResolvedCategory]:
  47. p = row.normalized_params
  48. lines: list[str] = [f" type: {row.category.value}"]
  49. if p.value_str:
  50. lines.append(f" value: {p.value_str}")
  51. if p.package:
  52. lines.append(f" package: {p.package}")
  53. if p.voltage_rating is not None:
  54. lines.append(f" voltage_rating: {p.voltage_rating}V")
  55. if p.current_rating is not None:
  56. lines.append(f" current_rating: {p.current_rating}A")
  57. if p.part_number:
  58. lines.append(f" part_number: {p.part_number}")
  59. if row.raw_value and row.raw_value != p.value_str:
  60. lines.append(f" raw_value: {row.raw_value!r}")
  61. subcat_lines = "\n".join(_fmt_subcat(s) for s in subcats)
  62. print(lines)
  63. prompt = (
  64. f"You are an electronics sourcing expert. Rank the top 3 {supplier.upper()} subcategories for this component, best match first.\n\n"
  65. "Component:\n"
  66. + "\n".join(lines)
  67. + "\n\nAvailable subcategories:\n"
  68. + subcat_lines
  69. + '\n\nReply ONLY with JSON: {"subcategories": ["<best match>", "<second best>", "<third best>"]} — use exact names from the list above, ordered by confidence'
  70. )
  71. try:
  72. text = complete(prompt, max_tokens=150)
  73. m = re.search(r'"subcategories"\s*:\s*\[([^\]]+)\]', text, re.DOTALL)
  74. if not m:
  75. return []
  76. names = re.findall(r'"([^"]+)"', m.group(1))
  77. results = []
  78. for name in names[:3]:
  79. r = _lookup(name.strip(), subcats)
  80. if r:
  81. results.append(r)
  82. return results
  83. except Exception as e:
  84. print(f" [resolver error] {e}")
  85. return []
  86. def _lookup(chosen: str, subcats: list[dict]) -> ResolvedCategory | None:
  87. cl = chosen.lower()
  88. for s in subcats:
  89. if s["name"].lower() == cl:
  90. return ResolvedCategory(s["name"], s["id"])
  91. for s in subcats:
  92. if cl in s["name"].lower() or s["name"].lower() in cl:
  93. return ResolvedCategory(s["name"], s["id"])
  94. return None
  95. def _load(path: Path) -> dict:
  96. return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
  97. def _save(cache: dict, path: Path) -> None:
  98. path.write_text(json.dumps(cache, indent=2, ensure_ascii=False), encoding="utf-8")