category_match.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. from __future__ import annotations
  2. from collections.abc import Callable
  3. from dataclasses import dataclass
  4. from functools import lru_cache
  5. from threading import local
  6. from typing import Any
  7. import requests
  8. from supply_infra.config import InfraSettings, get_infra_settings
  9. _SOURCE_TYPE = "实质"
  10. _TOP_K = 1
  11. _MIN_SCORE = 0.8
  12. class CategoryMatchError(RuntimeError):
  13. """The category matching service returned an unusable response."""
  14. @dataclass(frozen=True)
  15. class CategoryMatch:
  16. term: str
  17. stable_id: int
  18. name: str
  19. path: str
  20. description: str
  21. level: int
  22. score: float
  23. @property
  24. def reason(self) -> str:
  25. return (
  26. "category_match_api_v2: "
  27. f"path={self.path}; score={self.score:.4f}; stable_id={self.stable_id}"
  28. )
  29. class CategoryMatchClient:
  30. def __init__(
  31. self,
  32. settings: InfraSettings | None = None,
  33. *,
  34. session_factory: Callable[[], requests.Session] | None = None,
  35. ) -> None:
  36. self.settings = settings or get_infra_settings()
  37. self._session_factory = session_factory or requests.Session
  38. self._thread_local = local()
  39. def _session(self) -> requests.Session:
  40. session = getattr(self._thread_local, "session", None)
  41. if session is None:
  42. session = self._session_factory()
  43. self._thread_local.session = session
  44. return session
  45. def match_one(
  46. self,
  47. term: str,
  48. *,
  49. description: str = "",
  50. ) -> CategoryMatch | None:
  51. normalized_term = str(term).strip()
  52. if not normalized_term:
  53. raise ValueError("term must not be empty")
  54. payload = {
  55. "source_type": _SOURCE_TYPE,
  56. "top_k": _TOP_K,
  57. "min_score": _MIN_SCORE,
  58. "items": [
  59. {
  60. "term": normalized_term,
  61. "description": str(description or ""),
  62. }
  63. ],
  64. }
  65. try:
  66. response = self._session().post(
  67. self.settings.category_match_api_url,
  68. json=payload,
  69. timeout=self.settings.category_match_timeout_seconds,
  70. )
  71. response.raise_for_status()
  72. body = response.json()
  73. except (requests.RequestException, ValueError) as exc:
  74. raise CategoryMatchError(
  75. f"category match request failed for term={normalized_term!r}"
  76. ) from exc
  77. return _parse_match_response(normalized_term, body)
  78. def _parse_match_response(
  79. term: str,
  80. body: Any,
  81. ) -> CategoryMatch | None:
  82. if not isinstance(body, dict) or body.get("success") is not True:
  83. raise CategoryMatchError(f"category match response is unsuccessful for term={term!r}")
  84. items = body.get("items")
  85. if not isinstance(items, list) or len(items) != 1:
  86. raise CategoryMatchError(
  87. f"category match response must contain exactly one item for term={term!r}"
  88. )
  89. item = items[0]
  90. if not isinstance(item, dict) or str(item.get("term", "")).strip() != term:
  91. raise CategoryMatchError(f"category match response term mismatch for term={term!r}")
  92. matches = item.get("matches")
  93. if not isinstance(matches, list):
  94. raise CategoryMatchError(f"category match response has invalid matches for term={term!r}")
  95. if not matches:
  96. return None
  97. match = matches[0]
  98. if not isinstance(match, dict):
  99. raise CategoryMatchError(f"category match response has invalid match for term={term!r}")
  100. try:
  101. stable_id = int(match["stable_id"])
  102. score = float(match["score"])
  103. level = int(match["level"])
  104. except (KeyError, TypeError, ValueError) as exc:
  105. raise CategoryMatchError(
  106. f"category match response lacks typed fields for term={term!r}"
  107. ) from exc
  108. if score < _MIN_SCORE:
  109. return None
  110. name = str(match.get("name") or "").strip()
  111. path = str(match.get("path") or "").strip()
  112. if not name or not path:
  113. raise CategoryMatchError(
  114. f"category match response lacks name/path for term={term!r}"
  115. )
  116. return CategoryMatch(
  117. term=term,
  118. stable_id=stable_id,
  119. name=name,
  120. path=path,
  121. description=str(match.get("description") or ""),
  122. level=level,
  123. score=score,
  124. )
  125. @lru_cache
  126. def get_category_match_client() -> CategoryMatchClient:
  127. return CategoryMatchClient()