tavily_venue_config.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. """Tavily ``include_domains`` 与会场锚点主机列表:数据驱动(JSON),避免在业务代码里写死映射。
  2. 编辑 ``tavily_venue_domains.json`` 即可增删会场;或通过环境变量 / 配置指向自定义 JSON。
  3. """
  4. from __future__ import annotations
  5. import json
  6. import logging
  7. import os
  8. import re
  9. from functools import lru_cache
  10. from pathlib import Path
  11. from typing import Any, Dict, List, Optional
  12. from app.core.search.normalize import _venue_canonical_key
  13. logger = logging.getLogger(__name__)
  14. _DEFAULT_JSON = Path(__file__).resolve().with_name("tavily_venue_domains.json")
  15. _RE_SAFE_DOMAIN = re.compile(
  16. r"^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$",
  17. re.I,
  18. )
  19. def _sanitize_domains(raw: Any, *, limit: int = 3) -> List[str]:
  20. out: List[str] = []
  21. if not isinstance(raw, list):
  22. return out
  23. for x in raw:
  24. s = str(x).strip().lower().rstrip(".")
  25. if not s or "." not in s:
  26. continue
  27. if not _RE_SAFE_DOMAIN.match(s):
  28. logger.warning("tavily venue config: skip invalid domain %r", s)
  29. continue
  30. if s not in out:
  31. out.append(s)
  32. if len(out) >= limit:
  33. break
  34. return out
  35. def _resolve_config_path() -> Path:
  36. env_p = (os.environ.get("PAPERGRAPH_TAVILY_VENUE_DOMAINS_JSON") or "").strip()
  37. if env_p:
  38. ep = Path(env_p).expanduser()
  39. if ep.is_file():
  40. return ep
  41. logger.warning("tavily venue config: env path not a file: %s", ep)
  42. try:
  43. from ...settings import get_settings
  44. cfg = (getattr(get_settings(), "tavily_venue_domains_config_path", None) or "").strip()
  45. if cfg:
  46. cp = Path(cfg).expanduser()
  47. if cp.is_file():
  48. return cp
  49. logger.warning("tavily venue config: settings path not a file: %s", cp)
  50. except Exception:
  51. pass
  52. return _DEFAULT_JSON
  53. @lru_cache(maxsize=4)
  54. def _load_config_for_path(resolved_path: str) -> Dict[str, Any]:
  55. try:
  56. p = Path(resolved_path)
  57. data = json.loads(p.read_text(encoding="utf-8"))
  58. return data if isinstance(data, dict) else {}
  59. except FileNotFoundError:
  60. logger.error("tavily venue config missing: %s", resolved_path)
  61. except json.JSONDecodeError as e:
  62. logger.error("tavily venue config JSON invalid (%s): %s", resolved_path, e)
  63. except OSError as e:
  64. logger.error("tavily venue config read failed (%s): %s", resolved_path, e)
  65. return {}
  66. def _get_config_data() -> Dict[str, Any]:
  67. return _load_config_for_path(str(_resolve_config_path().resolve()))
  68. def clear_tavily_venue_config_cache() -> None:
  69. """测试或替换 JSON 后调用以失效缓存。"""
  70. _load_config_for_path.cache_clear()
  71. def get_official_proceedings_hosts() -> tuple[str, ...]:
  72. """用于锚点标题 / 关键词排序加权的官方 proceedings 主机列表。"""
  73. raw = _get_config_data().get("official_proceedings_hosts") or []
  74. hosts = _sanitize_domains(raw, limit=32)
  75. if hosts:
  76. return tuple(hosts)
  77. return tuple(_DEFAULT_BUILTIN_HOSTS)
  78. _DEFAULT_BUILTIN_HOSTS = (
  79. "proceedings.neurips.cc",
  80. "proceedings.mlr.press",
  81. "openaccess.thecvf.com",
  82. "aclanthology.org",
  83. "aaai.org",
  84. "ijcai.org",
  85. )
  86. def _canonical_include_map() -> Dict[str, List[str]]:
  87. data = _get_config_data().get("include_domains_by_canonical") or {}
  88. out: Dict[str, List[str]] = {}
  89. if not isinstance(data, dict):
  90. return out
  91. for k, v in data.items():
  92. key = str(k).strip().lower()
  93. if not key:
  94. continue
  95. doms = _sanitize_domains(v)
  96. if doms:
  97. out[key] = doms
  98. return out
  99. def _condition_matches(vl: str, cond: Any) -> bool:
  100. if not isinstance(cond, dict):
  101. return False
  102. if "substring" in cond:
  103. sub = str(cond.get("substring") or "").lower()
  104. return bool(sub) and sub in vl
  105. if "regex" in cond:
  106. pat = str(cond.get("regex") or "")
  107. if not pat:
  108. return False
  109. try:
  110. return bool(re.search(pat, vl))
  111. except re.error as e:
  112. logger.warning("tavily venue config: bad regex %r: %s", pat, e)
  113. return False
  114. return False
  115. def _first_domains_from_substring_rules(vl: str) -> Optional[List[str]]:
  116. rules = _get_config_data().get("substring_rules") or []
  117. if not isinstance(rules, list):
  118. return None
  119. for rule in rules:
  120. if not isinstance(rule, dict):
  121. continue
  122. doms = _sanitize_domains(rule.get("domains"))
  123. if not doms:
  124. continue
  125. any_conds = rule.get("any")
  126. if not isinstance(any_conds, list):
  127. continue
  128. ok = False
  129. for c in any_conds:
  130. if _condition_matches(vl, c):
  131. ok = True
  132. break
  133. if ok:
  134. return doms
  135. return None
  136. def tavily_include_domains_for_venue(venue: Optional[str]) -> Optional[List[str]]:
  137. """根据会场字符串返回 Tavily ``include_domains``(数据来自 JSON)。
  138. 返回 ``None`` 表示不限制域名。ICLR / 泛 ACM DL 等仍建议仅在 JSON 中不配规则。
  139. """
  140. raw = (venue or "").strip()
  141. if not raw:
  142. return None
  143. key = _venue_canonical_key(raw)
  144. if key:
  145. m = _canonical_include_map().get(key)
  146. if m:
  147. return list(m)
  148. vl = raw.lower()
  149. return _first_domains_from_substring_rules(vl)