customer_taxonomy.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. # -*- coding: utf-8 -*-
  2. """Strict customer taxonomy and classification helpers for Wuling outreach.
  3. Source intentionally uses unicode escapes for Chinese labels so scripted edits do
  4. not depend on the Windows console code page. Runtime values are normal unicode.
  5. """
  6. from __future__ import annotations
  7. import re
  8. from typing import Any, Mapping, Sequence, Tuple
  9. def zh(value: str) -> str:
  10. return value.encode("ascii").decode("unicode_escape")
  11. AUTO_CHANNEL = zh(r"\u6c7d\u8f66\u6e20\u9053\u5408\u4f5c\u4f19\u4f34")
  12. BULK_OPERATOR = zh(r"\u6279\u91cf\u91c7\u8d2d\u4e0e\u8fd0\u8425\u5ba2\u6237")
  13. USED_TRANSITION = zh(r"\u4e8c\u624b\u8f66\u8f6c\u578b\u5019\u9009")
  14. PLATFORM_CHANNEL = zh(r"\u5e73\u53f0\u4e0e\u884c\u4e1a\u6e20\u9053")
  15. ECOSYSTEM_SUPPORT = zh(r"\u751f\u6001\u652f\u6301\u8d44\u6e90")
  16. CAR_IMPORTER = zh(r"\u6c7d\u8f66\u8fdb\u53e3\u5546")
  17. NATIONAL_AGENT = zh(r"\u5168\u56fd\u4ee3\u7406\u5546")
  18. NATIONAL_DISTRIBUTOR = zh(r"\u5168\u56fd\u5206\u9500\u5546")
  19. REGIONAL_DISTRIBUTOR = zh(r"\u533a\u57df\u5206\u9500\u5546")
  20. MULTI_BRAND_DEALER = zh(r"\u591a\u54c1\u724c\u7ecf\u9500\u5546")
  21. COMMERCIAL_VEHICLE_CHANNEL = zh(r"\u5546\u7528\u8f66\u6e20\u9053\u5546")
  22. CHINA_BRAND_DEALER = zh(r"\u4e2d\u56fd\u54c1\u724c\u7ecf\u9500\u5546")
  23. NEV_SMALL_VEHICLE_CHANNEL = zh(r"\u65b0\u80fd\u6e90\u6216\u5c0f\u578b\u8f66\u6e20\u9053\u5546")
  24. CAR_RENTAL = zh(r"\u6c7d\u8f66\u79df\u8d41\u516c\u53f8")
  25. LONG_TERM_RENTAL = zh(r"\u957f\u671f\u79df\u8d41\u516c\u53f8")
  26. CORPORATE_FLEET = zh(r"\u4f01\u4e1a\u8f66\u961f")
  27. LOGISTICS_DELIVERY = zh(r"\u7269\u6d41\u914d\u9001\u4f01\u4e1a")
  28. GOV_PROCUREMENT = zh(r"\u653f\u5e9c\u6216\u673a\u6784\u91c7\u8d2d\u65b9")
  29. USED_CHAIN = zh(r"\u8fde\u9501\u4e8c\u624b\u8f66\u4f01\u4e1a")
  30. USED_PHYSICAL = zh(r"\u5b9e\u4f53\u4e8c\u624b\u8f66\u4f01\u4e1a")
  31. USED_IMPORTER = zh(r"\u8fdb\u53e3\u4e8c\u624b\u8f66\u4f01\u4e1a")
  32. NEW_USED_COMPREHENSIVE = zh(r"\u65b0\u8f66\u4e0e\u4e8c\u624b\u8f66\u7efc\u5408\u4f01\u4e1a")
  33. CAR_MARKETPLACE = zh(r"\u6c7d\u8f66\u4ea4\u6613\u5e73\u53f0")
  34. CAR_ASSOCIATION = zh(r"\u6c7d\u8f66\u534f\u4f1a")
  35. CHAMBER = zh(r"\u5546\u4f1a")
  36. DEALER_ALLIANCE = zh(r"\u8f66\u5546\u8054\u76df")
  37. DEALER_REFERRAL = zh(r"\u7ecf\u9500\u5546\u8d44\u6e90\u5f15\u8350\u673a\u6784")
  38. AFTER_SALES_NETWORK = zh(r"\u552e\u540e\u670d\u52a1\u7f51\u7edc")
  39. PARTS_WAREHOUSE = zh(r"\u5907\u4ef6\u4f9b\u5e94\u4e0e\u4ed3\u50a8\u4f01\u4e1a")
  40. IMPORT_CERTIFICATION = zh(r"\u8fdb\u53e3\u8ba4\u8bc1\u4e0e\u4e0a\u724c\u673a\u6784")
  41. FINANCE_INSURANCE = zh(r"\u91d1\u878d\u4fdd\u9669\u673a\u6784")
  42. VEHICLE_LOGISTICS = zh(r"\u8f66\u8f86\u7269\u6d41\u4f01\u4e1a")
  43. CUSTOMER_TAXONOMY = {
  44. AUTO_CHANNEL: [
  45. CAR_IMPORTER,
  46. NATIONAL_AGENT,
  47. NATIONAL_DISTRIBUTOR,
  48. REGIONAL_DISTRIBUTOR,
  49. MULTI_BRAND_DEALER,
  50. COMMERCIAL_VEHICLE_CHANNEL,
  51. CHINA_BRAND_DEALER,
  52. NEV_SMALL_VEHICLE_CHANNEL,
  53. ],
  54. BULK_OPERATOR: [
  55. CAR_RENTAL,
  56. LONG_TERM_RENTAL,
  57. CORPORATE_FLEET,
  58. LOGISTICS_DELIVERY,
  59. GOV_PROCUREMENT,
  60. ],
  61. USED_TRANSITION: [
  62. USED_CHAIN,
  63. USED_PHYSICAL,
  64. USED_IMPORTER,
  65. NEW_USED_COMPREHENSIVE,
  66. ],
  67. PLATFORM_CHANNEL: [
  68. CAR_MARKETPLACE,
  69. CAR_ASSOCIATION,
  70. CHAMBER,
  71. DEALER_ALLIANCE,
  72. DEALER_REFERRAL,
  73. ],
  74. ECOSYSTEM_SUPPORT: [
  75. AFTER_SALES_NETWORK,
  76. PARTS_WAREHOUSE,
  77. IMPORT_CERTIFICATION,
  78. FINANCE_INSURANCE,
  79. VEHICLE_LOGISTICS,
  80. ],
  81. }
  82. VALID_ATTRIBUTES = set(CUSTOMER_TAXONOMY.keys())
  83. VALID_CUSTOMER_TYPES = {item for items in CUSTOMER_TAXONOMY.values() for item in items}
  84. TYPE_TO_ATTRIBUTE = {item: attr for attr, items in CUSTOMER_TAXONOMY.items() for item in items}
  85. KEY_NAME = zh(r"\u516c\u53f8\u59d3\u540d")
  86. KEY_ATTRIBUTE = zh(r"\u5ba2\u6237\u5c5e\u6027")
  87. KEY_TYPE = zh(r"\u5ba2\u6237\u7c7b\u578b")
  88. KEY_BUSINESS = zh(r"\u4e3b\u8425\u4e1a\u52a1")
  89. KEY_NOTE = zh(r"\u5907\u6ce8")
  90. OLD_TO_NEW_TYPE = {
  91. zh(r"\u533a\u57df\u65b0\u8f66\u7ecf\u9500\u516c\u53f8"): REGIONAL_DISTRIBUTOR,
  92. zh(r"\u533a\u57df\u591a\u54c1\u724c\u6388\u6743\u7ecf\u9500\u516c\u53f8"): MULTI_BRAND_DEALER,
  93. zh(r"\u5168\u56fd\u591a\u54c1\u724c\u6c7d\u8f66\u5206\u9500\u96c6\u56e2"): NATIONAL_DISTRIBUTOR,
  94. zh(r"\u5168\u56fd\u591a\u54c1\u724c\u8fdb\u53e3\u4e0e\u5206\u9500\u96c6\u56e2"): NATIONAL_DISTRIBUTOR,
  95. zh(r"\u6c7d\u8f66\u8fdb\u53e3\u4e0e\u5206\u9500\u516c\u53f8"): CAR_IMPORTER,
  96. zh(r"\u6c7d\u8f66\u8fdb\u53e3\u4e0e\u5206\u9500\u8fd0\u8425\u516c\u53f8"): CAR_IMPORTER,
  97. zh(r"\u8f7b\u578b\u5546\u7528\u8f66\u8fdb\u53e3\u4e0e\u5206\u9500\u516c\u53f8"): COMMERCIAL_VEHICLE_CHANNEL,
  98. zh(r"\u591a\u54c1\u724c\u6c7d\u8f66\u4e0e\u5546\u7528\u8f66\u5206\u9500\u516c\u53f8"): COMMERCIAL_VEHICLE_CHANNEL,
  99. zh(r"\u6c7d\u8f66\u5bfc\u8d2d\u3001\u5185\u5bb9\u4e0e\u4ea4\u6613\u5e73\u53f0"): CAR_MARKETPLACE,
  100. zh(r"\u6c7d\u8f66\u4e0e\u5546\u7528\u8f66\u4ea4\u6613\u5e73\u53f0"): CAR_MARKETPLACE,
  101. zh(r"\u7efc\u5408\u6c7d\u8f66\u4ea4\u6613\u4e0e\u4e13\u4e1a\u76ee\u5f55\u5e73\u53f0"): CAR_MARKETPLACE,
  102. zh(r"\u7efc\u5408\u5206\u7c7b\u4fe1\u606f\u4e0e\u6c7d\u8f66\u4ea4\u6613\u5e73\u53f0"): CAR_MARKETPLACE,
  103. zh(r"\u8ba4\u8bc1\u4e8c\u624b\u8f66\u4ea4\u6613\u5e73\u53f0"): CAR_MARKETPLACE,
  104. zh(r"\u96c6\u56e2\u5316\u4e8c\u624b\u8f66\u4ea4\u6613\u4e0e\u7f6e\u6362\u5e73\u53f0"): CAR_MARKETPLACE,
  105. zh(r"\u6c7d\u8f66\u4ea4\u6613\u4e0e\u7ecf\u9500\u5546\u83b7\u5ba2\u5e73\u53f0"): CAR_MARKETPLACE,
  106. zh(r"\u8ba4\u8bc1\u4e8c\u624b\u8f66\u4e0e\u6c7d\u8f66\u4ea4\u6613\u5e73\u53f0"): CAR_MARKETPLACE,
  107. zh(r"\u6c7d\u8f66\u8fdb\u53e3\u5546\u884c\u4e1a\u534f\u4f1a"): CAR_ASSOCIATION,
  108. zh(r"\u6c7d\u8f66\u5de5\u4e1a\u4e0e\u884c\u4e1a\u534f\u4f1a"): CAR_ASSOCIATION,
  109. zh(r"\u65e0\u53f8\u673a\u6c7d\u8f66\u79df\u8d41\u884c\u4e1a\u8054\u5408\u4f1a"): CAR_ASSOCIATION,
  110. zh(r"\u884c\u4e1aB2B\u64ae\u5408\u4e0e\u4f1a\u8bae\u5e73\u53f0"): DEALER_REFERRAL,
  111. zh(r"\u6c7d\u8f66\u884c\u4e1a\u5c55\u4f1a\u4e0e\u4f01\u4e1a\u6d3b\u52a8\u5e73\u53f0"): DEALER_REFERRAL,
  112. zh(r"\u4e8c\u624b\u8f66\u5546"): USED_PHYSICAL,
  113. zh(r"\u4e8c\u624b\u8f66\u5546/\u4e8c\u624b\u8f66\u5e73\u53f0"): USED_PHYSICAL,
  114. zh(r"\u5546\u7528\u8f66/\u8f66\u961f\u6e20\u9053"): COMMERCIAL_VEHICLE_CHANNEL,
  115. zh(r"\u79df\u8d41/\u8f66\u961f\u516c\u53f8"): CAR_RENTAL,
  116. zh(r"\u8fdb\u53e3\u5546/\u6c7d\u8f66\u96c6\u56e2"): CAR_IMPORTER,
  117. zh(r"\u591a\u54c1\u724c\u7ecf\u9500\u5546/showroom"): MULTI_BRAND_DEALER,
  118. }
  119. QUESTION_MARK_RE = re.compile(r"\?{3,}")
  120. SPLIT_CLASS_RE = re.compile(r"[\n\r;/??,?]+")
  121. def clean(value: Any) -> str:
  122. if value is None:
  123. return ""
  124. return re.sub(r"\s+", " ", str(value).strip())
  125. def contains_any(text: str, keywords: Sequence[str]) -> bool:
  126. lower = text.casefold()
  127. return any(keyword.casefold() in lower for keyword in keywords)
  128. def is_valid_pair(attribute: str, customer_type: str) -> bool:
  129. return clean(customer_type) in CUSTOMER_TAXONOMY.get(clean(attribute), [])
  130. def type_to_attribute(customer_type: str) -> str:
  131. return TYPE_TO_ATTRIBUTE.get(clean(customer_type), "")
  132. def _split_class_values(value: str) -> list[str]:
  133. return [item for item in (clean(part) for part in SPLIT_CLASS_RE.split(value or "")) if item]
  134. def _match_old_type(text: str) -> str:
  135. for old, new in OLD_TO_NEW_TYPE.items():
  136. if old.casefold() in text.casefold():
  137. return new
  138. return ""
  139. def _record_text(record: Mapping[str, Any], sheet_name: str = "", sources: Sequence[str] = ()) -> str:
  140. preferred = [
  141. sheet_name,
  142. " ".join(str(item) for item in sources),
  143. str(record.get("name", "")),
  144. str(record.get("attribute", "")),
  145. str(record.get("type", "")),
  146. str(record.get("business", "")),
  147. str(record.get("note", "")),
  148. str(record.get("link", "")),
  149. str(record.get("website", "")),
  150. str(record.get(KEY_NAME, "")),
  151. str(record.get(KEY_ATTRIBUTE, "")),
  152. str(record.get(KEY_TYPE, "")),
  153. str(record.get(KEY_BUSINESS, "")),
  154. str(record.get(KEY_NOTE, "")),
  155. str(record.get("??", "")),
  156. ]
  157. return " ".join(item for item in preferred if item).casefold()
  158. def _platform_type(text: str) -> str:
  159. if contains_any(text, ["chamber", "commerce chamber", zh(r"\u5546\u4f1a")]):
  160. return CHAMBER
  161. if contains_any(text, ["association", "federation", "f?d?ration", "federation", "syndicat", zh(r"\u534f\u4f1a")]):
  162. return CAR_ASSOCIATION
  163. if contains_any(text, ["alliance", "union", zh(r"\u8054\u76df")]):
  164. return DEALER_ALLIANCE
  165. if contains_any(text, ["lead", "referral", "b2b", "event", "exhibition", "salon", zh(r"\u5f15\u8350"), zh(r"\u8d44\u6e90"), zh(r"\u64ae\u5408"), zh(r"\u5c55\u4f1a"), zh(r"\u83b7\u5ba2")]):
  166. return DEALER_REFERRAL
  167. return CAR_MARKETPLACE
  168. def _ecosystem_type(text: str) -> str:
  169. if contains_any(text, ["spare", "parts", "piece", "pi?ce", "warehouse", "storage", zh(r"\u5907\u4ef6"), zh(r"\u914d\u4ef6"), zh(r"\u4ed3\u50a8")]):
  170. return PARTS_WAREHOUSE
  171. if contains_any(text, ["homolog", "certification", "registration", "immatriculation", zh(r"\u8ba4\u8bc1"), zh(r"\u4e0a\u724c")]):
  172. return IMPORT_CERTIFICATION
  173. if contains_any(text, ["insurance", "assurance", "finance", "financement", "bank", zh(r"\u4fdd\u9669"), zh(r"\u91d1\u878d")]):
  174. return FINANCE_INSURANCE
  175. if contains_any(text, ["vehicle logistics", "transport automobile", "shipping", zh(r"\u8f66\u8f86\u7269\u6d41")]):
  176. return VEHICLE_LOGISTICS
  177. return AFTER_SALES_NETWORK
  178. def _bulk_type(text: str) -> str:
  179. if contains_any(text, ["government", "public agency", "tender", "procurement", zh(r"\u653f\u5e9c"), zh(r"\u673a\u6784"), zh(r"\u91c7\u8d2d")]):
  180. return GOV_PROCUREMENT
  181. if contains_any(text, ["logistics", "delivery", "courier", "express", zh(r"\u7269\u6d41"), zh(r"\u914d\u9001")]):
  182. return LOGISTICS_DELIVERY
  183. if contains_any(text, ["corporate fleet", "fleet", "flotte", zh(r"\u4f01\u4e1a\u8f66\u961f"), zh(r"\u8f66\u961f")]):
  184. return CORPORATE_FLEET
  185. if contains_any(text, ["long-term", "long term", "lld", "leasing longue", zh(r"\u957f\u671f\u79df\u8d41")]):
  186. return LONG_TERM_RENTAL
  187. return CAR_RENTAL
  188. def _used_type(text: str) -> str:
  189. if contains_any(text, ["chain", "network", "r?seau", "reseau", "branches", "succursale", "multi-city", zh(r"\u8fde\u9501"), zh(r"\u591a\u95e8\u5e97")]):
  190. return USED_CHAIN
  191. if contains_any(text, ["import", "importation", zh(r"\u8fdb\u53e3")]):
  192. return USED_IMPORTER
  193. if contains_any(text, ["new vehicle", "v?hicules neufs", "vehicules neufs", "neuf", zh(r"\u65b0\u8f66")]):
  194. return NEW_USED_COMPREHENSIVE
  195. return USED_PHYSICAL
  196. def _auto_channel_type(text: str) -> str:
  197. if contains_any(text, ["commercial vehicle", "utilitaire", "camion", "truck", "van", "bus", "mpv", zh(r"\u5546\u7528\u8f66"), zh(r"\u8d27\u8f66"), zh(r"\u5361\u8f66")]):
  198. return COMMERCIAL_VEHICLE_CHANNEL
  199. if contains_any(text, ["byd", "jac", "dfsk", "foton", "changan", "chery", "geely", "mg", "haval", "gwm", "leapmotor", zh(r"\u4e2d\u56fd\u54c1\u724c")]):
  200. return CHINA_BRAND_DEALER
  201. if contains_any(text, ["ev", "electric", "hybrid", "new energy", "mini", "micro", "small car", zh(r"\u65b0\u80fd\u6e90"), zh(r"\u5c0f\u578b"), zh(r"\u5fae\u578b")]):
  202. return NEV_SMALL_VEHICLE_CHANNEL
  203. if contains_any(text, ["national agent", "exclusive agent", "importateur exclusif", zh(r"\u5168\u56fd\u4ee3\u7406")]):
  204. return NATIONAL_AGENT
  205. if contains_any(text, ["national distributor", "nationwide", "dealer network", "r?seau", "reseau", "group", "groupe", "succursale", zh(r"\u5168\u56fd\u5206\u9500"), zh(r"\u5168\u56fd"), zh(r"\u96c6\u56e2"), zh(r"\u7f51\u7edc")]):
  206. return NATIONAL_DISTRIBUTOR
  207. if contains_any(text, ["importateur", "importer", "importation", zh(r"\u8fdb\u53e3")]):
  208. return CAR_IMPORTER
  209. if contains_any(text, ["distributeur", "distribution", zh(r"\u5206\u9500")]):
  210. return REGIONAL_DISTRIBUTOR
  211. return MULTI_BRAND_DEALER
  212. def classify_from_text(text: str) -> Tuple[str, str]:
  213. text = clean(text).casefold()
  214. old = _match_old_type(text)
  215. if old:
  216. return type_to_attribute(old), old
  217. # Strong non-dealer classes first, then dealership/channel evidence.
  218. if contains_any(text, ["association", "federation", "f?d?ration", "chamber", "marketplace", "annuaire", "directory", "classified", "portal", "b2b", "exhibition", zh(r"\u5e73\u53f0"), zh(r"\u534f\u4f1a"), zh(r"\u5546\u4f1a"), zh(r"\u8054\u76df"), zh(r"\u5f15\u8350"), zh(r"\u8d44\u6e90"), zh(r"\u76ee\u5f55")]):
  219. item = _platform_type(text)
  220. return PLATFORM_CHANNEL, item
  221. if contains_any(text, ["after-sales", "service network", "maintenance", "repair", "garage", "spare", "parts", "homolog", "certification", "registration", "insurance", "assurance", "finance", "vehicle logistics", zh(r"\u552e\u540e"), zh(r"\u7ef4\u4fee"), zh(r"\u5907\u4ef6"), zh(r"\u914d\u4ef6"), zh(r"\u8ba4\u8bc1"), zh(r"\u4e0a\u724c"), zh(r"\u91d1\u878d"), zh(r"\u4fdd\u9669"), zh(r"\u8f66\u8f86\u7269\u6d41")]):
  222. item = _ecosystem_type(text)
  223. return ECOSYSTEM_SUPPORT, item
  224. if contains_any(text, ["rent", "rental", "location", "leasing", "fleet", "flotte", "logistics", "delivery", "procurement", zh(r"\u79df\u8d41"), zh(r"\u79df\u8f66"), zh(r"\u8f66\u961f"), zh(r"\u7269\u6d41"), zh(r"\u914d\u9001"), zh(r"\u91c7\u8d2d")]):
  225. item = _bulk_type(text)
  226. return BULK_OPERATOR, item
  227. if contains_any(text, ["occasion", "used", "second hand", "second-hand", "pre-owned", "reprise", "parc occasion", "occaz", zh(r"\u4e8c\u624b"), zh(r"\u7f6e\u6362")]):
  228. item = _used_type(text)
  229. return USED_TRANSITION, item
  230. if contains_any(text, ["auto", "automobile", "cars", "motors", "concessionnaire", "dealer", "showroom", "vehicle", "v?hicule", "vehicule", "neuf", "import", "distributeur", "distribution", "multimarque", "multi-brand", "brand", "marque", zh(r"\u6c7d\u8f66"), zh(r"\u65b0\u8f66"), zh(r"\u5c55\u5385"), zh(r"\u7ecf\u9500"), zh(r"\u5206\u9500"), zh(r"\u8fdb\u53e3"), zh(r"\u591a\u54c1\u724c")]):
  231. item = _auto_channel_type(text)
  232. return AUTO_CHANNEL, item
  233. return "", ""
  234. def normalize_existing_classification(attribute: str, customer_type: str, evidence_text: str = "") -> Tuple[str, str]:
  235. attr = clean(attribute)
  236. typ = clean(customer_type)
  237. if is_valid_pair(attr, typ) and "\n" not in attr and "\n" not in typ:
  238. return attr, typ
  239. for part in _split_class_values(typ):
  240. old = OLD_TO_NEW_TYPE.get(part)
  241. if old:
  242. return type_to_attribute(old), old
  243. if part in VALID_CUSTOMER_TYPES:
  244. return type_to_attribute(part), part
  245. if attr in VALID_ATTRIBUTES and typ in CUSTOMER_TAXONOMY.get(attr, []):
  246. return attr, typ
  247. return classify_from_text(" ".join([attribute or "", customer_type or "", evidence_text or ""]))
  248. def classify_attribute_type(record: Mapping[str, Any], sheet_name: str = "", sources: Sequence[str] = ()) -> Tuple[str, str]:
  249. attr = clean(record.get("attribute") or record.get(KEY_ATTRIBUTE) or "")
  250. typ = clean(record.get("type") or record.get(KEY_TYPE) or "")
  251. text = _record_text(record, sheet_name, sources)
  252. normalized = normalize_existing_classification(attr, typ, text)
  253. if normalized != ("", ""):
  254. return normalized
  255. return classify_from_text(text)