matcher.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. from __future__ import annotations
  2. import csv
  3. import io
  4. import re
  5. import unicodedata
  6. from dataclasses import dataclass, field
  7. from functools import lru_cache
  8. from pathlib import Path
  9. from rapidfuzz import fuzz
  10. _DASH_TRANSLATION = str.maketrans(
  11. {
  12. "‐": "-",
  13. "‑": "-",
  14. "‒": "-",
  15. "–": "-",
  16. "—": "-",
  17. "―": "-",
  18. "−": "-",
  19. "﹣": "-",
  20. "-": "-",
  21. "﹕": ":",
  22. ":": ":",
  23. }
  24. )
  25. _CODE_PATTERN = re.compile(
  26. r"(?<![A-Za-z0-9])"
  27. r"(?P<code>"
  28. r"[A-Za-z][A-Za-z/]*"
  29. r"(?:\s*[A-Za-z])?"
  30. r"\s*(?::\s*[A-Za-z])?"
  31. r"\s*\d"
  32. r"[A-Za-z0-9./:\-‐‑‒–—―−﹣-]*"
  33. r")",
  34. re.IGNORECASE,
  35. )
  36. _YEAR_PATTERN = re.compile(r"-(19\d{2}|20\d{2})$")
  37. _SHORT_YEAR_PATTERN = re.compile(r"^(.*)-(\d{2})$")
  38. def _nfkc(value: str) -> str:
  39. return unicodedata.normalize("NFKC", value or "").translate(_DASH_TRANSLATION)
  40. def normalize_code(value: str) -> str:
  41. normalized = _nfkc(value).upper()
  42. return re.sub(r"[^A-Z0-9/.:+\-]", "", normalized)
  43. def normalize_title(value: str) -> str:
  44. normalized = _nfkc(value).lower()
  45. return "".join(
  46. character
  47. for character in normalized
  48. if unicodedata.category(character)[0] not in {"P", "S", "Z"}
  49. )
  50. def extract_query_parts(keyword: str) -> tuple[str, str]:
  51. normalized = _nfkc(keyword)
  52. prepared = re.sub(r"\s*([/.:+\-])\s*", r"\1", normalized)
  53. prepared = re.sub(r"(?<=[A-Za-z])\s+(?=[A-Za-z0-9])", "", prepared)
  54. prepared = re.sub(r"(?<=[0-9])\s+(?=[0-9])", "", prepared)
  55. match = _CODE_PATTERN.search(prepared)
  56. if not match:
  57. return "", normalize_title(prepared)
  58. query_code = normalize_code(match.group("code"))
  59. remaining = prepared[: match.start()] + " " + prepared[match.end() :]
  60. return query_code, normalize_title(remaining)
  61. @lru_cache(maxsize=32768)
  62. def _year(code: str) -> int | None:
  63. match = _YEAR_PATTERN.search(code)
  64. return int(match.group(1)) if match else None
  65. @lru_cache(maxsize=32768)
  66. def _without_year(code: str) -> str:
  67. return _YEAR_PATTERN.sub("", code)
  68. @lru_cache(maxsize=32768)
  69. def _relax_standard_type(code: str) -> str:
  70. first_digit = next((index for index, char in enumerate(code) if char.isdigit()), len(code))
  71. prefix = code[:first_digit].replace("/T", "")
  72. return prefix + code[first_digit:]
  73. @lru_cache(maxsize=32768)
  74. def _loose_code(code: str) -> str:
  75. return re.sub(r"[^A-Z0-9]", "", code)
  76. @lru_cache(maxsize=65536)
  77. def _two_digit_year_equivalent(left: str, right: str) -> bool:
  78. for short_code, full_code in ((left, right), (right, left)):
  79. short_match = _SHORT_YEAR_PATTERN.match(short_code)
  80. full_year = _year(full_code)
  81. if not short_match or full_year is None:
  82. continue
  83. if short_match.group(1) != _without_year(full_code):
  84. continue
  85. if int(short_match.group(2)) == full_year % 100:
  86. return True
  87. return False
  88. @lru_cache(maxsize=131072)
  89. def code_similarity(query_code: str, candidate_code: str) -> float:
  90. if not query_code or not candidate_code:
  91. return 0.0
  92. if query_code == candidate_code:
  93. return 1.0
  94. if _two_digit_year_equivalent(query_code, candidate_code):
  95. return 0.98
  96. if _loose_code(query_code) == _loose_code(candidate_code):
  97. return 0.98
  98. query_relaxed = _relax_standard_type(query_code)
  99. candidate_relaxed = _relax_standard_type(candidate_code)
  100. if query_relaxed == candidate_relaxed:
  101. return 0.93
  102. if _two_digit_year_equivalent(query_relaxed, candidate_relaxed):
  103. return 0.93
  104. query_year = _year(query_code)
  105. candidate_year = _year(candidate_code)
  106. if query_year is None and candidate_year is not None:
  107. query_without_type = _relax_standard_type(query_code)
  108. candidate_stem = _without_year(_relax_standard_type(candidate_code))
  109. if query_without_type == candidate_stem:
  110. return 0.90
  111. if candidate_stem.startswith(query_without_type):
  112. suffix = candidate_stem[len(query_without_type) :]
  113. if suffix and suffix[0] in {".", "/", ":", "-"}:
  114. return 0.80
  115. if query_year is not None and candidate_year is not None and query_year != candidate_year:
  116. if _without_year(query_relaxed) == _without_year(candidate_relaxed):
  117. return 0.70
  118. return 0.0
  119. if query_year is not None and candidate_year == query_year:
  120. ratio = fuzz.ratio(query_code, candidate_code) / 100
  121. if ratio >= 0.88:
  122. return 0.75
  123. return 0.0
  124. def _han_count(value: str) -> int:
  125. return sum("\u4e00" <= character <= "\u9fff" for character in value)
  126. @lru_cache(maxsize=131072)
  127. def title_similarity(query_title: str, candidate_title: str) -> float:
  128. if not query_title or not candidate_title:
  129. return 0.0
  130. if query_title == candidate_title:
  131. return 1.0
  132. if candidate_title in query_title and len(candidate_title) >= 4:
  133. coverage = len(candidate_title) / len(query_title)
  134. return min(0.98, 0.90 + 0.08 * coverage)
  135. if query_title in candidate_title and len(query_title) >= 4:
  136. coverage = len(query_title) / len(candidate_title)
  137. return min(0.95, 0.65 + 0.35 * coverage)
  138. if _han_count(query_title) < 6:
  139. return 0.0
  140. return fuzz.WRatio(query_title, candidate_title) / 100
  141. @dataclass(frozen=True)
  142. class Standard:
  143. code: str
  144. normalized_code: str
  145. title: str
  146. status: str
  147. relation_text: str
  148. implement_date: str
  149. official_key: str = field(init=False)
  150. alias_key: str = field(init=False)
  151. normalized_title: str = field(init=False)
  152. official_year: int = field(init=False)
  153. def __post_init__(self) -> None:
  154. official_key = normalize_code(self.code or self.normalized_code)
  155. object.__setattr__(self, "official_key", official_key)
  156. object.__setattr__(self, "alias_key", normalize_code(self.normalized_code))
  157. object.__setattr__(self, "normalized_title", normalize_title(self.title))
  158. object.__setattr__(self, "official_year", _year(official_key) or 0)
  159. def code_score(self, query_code: str) -> float:
  160. score = code_similarity(query_code, self.official_key)
  161. if self.alias_key and self.alias_key != self.official_key:
  162. query_to_alias = code_similarity(query_code, self.alias_key)
  163. alias_to_official = code_similarity(self.alias_key, self.official_key)
  164. score = max(score, min(query_to_alias, alias_to_official))
  165. return score
  166. def as_candidate(self, score: float) -> dict[str, str | float]:
  167. return {
  168. "code": self.code,
  169. "title": self.title,
  170. "status": self.status,
  171. "relation_text": self.relation_text,
  172. "implement_date": self.implement_date,
  173. "match_score": round(score, 4),
  174. }
  175. class StandardMatcher:
  176. def __init__(self, standards: list[Standard]):
  177. self._standards = standards
  178. @classmethod
  179. def from_csv(cls, path: str | Path) -> "StandardMatcher":
  180. csv_path = Path(path)
  181. content = cls._read_csv_text(csv_path)
  182. reader = csv.DictReader(io.StringIO(content))
  183. standards = []
  184. for row in reader:
  185. code = (row.get("csres_code") or "").strip()
  186. normalized_code = (row.get("csres_normalized_code") or "").strip()
  187. title = (row.get("csres_title") or "").strip()
  188. if not (code or normalized_code):
  189. continue
  190. standards.append(
  191. Standard(
  192. code=code or normalized_code,
  193. normalized_code=normalized_code or code,
  194. title=title,
  195. status=(row.get("csres_status") or "").strip(),
  196. relation_text=(row.get("csres_relation_text") or "").strip(),
  197. implement_date=(row.get("csres_implement_date") or "").strip(),
  198. )
  199. )
  200. return cls(standards)
  201. @staticmethod
  202. def _read_csv_text(path: Path) -> str:
  203. for encoding in ("utf-8-sig", "gb18030"):
  204. try:
  205. return path.read_text(encoding=encoding)
  206. except UnicodeDecodeError:
  207. continue
  208. raise UnicodeDecodeError("utf-8", b"", 0, 1, f"无法识别文件编码: {path}")
  209. def search(self, keyword: str, limit: int = 3) -> list[dict[str, str | float]]:
  210. query_code, query_title = extract_query_parts(keyword)
  211. if not query_code and not query_title:
  212. return []
  213. scored = []
  214. for standard in self._standards:
  215. code_score = standard.code_score(query_code) if query_code else 0.0
  216. name_score = (
  217. title_similarity(query_title, standard.normalized_title)
  218. if query_title
  219. else 0.0
  220. )
  221. code_relevant = bool(query_code) and code_score >= 0.70
  222. name_relevant = bool(query_title) and name_score >= 0.75
  223. if not (code_relevant or name_relevant):
  224. continue
  225. if query_code and query_title:
  226. higher = max(code_score, name_score)
  227. lower = min(code_score, name_score)
  228. final_score = higher * 0.70 + lower * 0.30
  229. elif query_code:
  230. final_score = code_score
  231. else:
  232. final_score = name_score
  233. scored.append((standard, final_score, code_score))
  234. deduplicated: dict[str, tuple[Standard, float, float]] = {}
  235. for standard, score, code_score in scored:
  236. key = standard.official_key
  237. current = deduplicated.get(key)
  238. if current is None or (score, code_score) > (current[1], current[2]):
  239. deduplicated[key] = (standard, score, code_score)
  240. ordered = sorted(
  241. deduplicated.values(),
  242. key=lambda item: (
  243. -item[1],
  244. -item[2],
  245. -item[0].official_year,
  246. item[0].official_key,
  247. ),
  248. )
  249. return [standard.as_candidate(score) for standard, score, _ in ordered[:limit]]