parsing.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. """搜索意图解析 —— LLM 意图提取、JSON 修复、重试纠错与意图规范化."""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. import re
  6. from datetime import datetime
  7. from typing import TYPE_CHECKING, Any
  8. logger = logging.getLogger(__name__)
  9. from app.core.search.paper_searcher import _sanitize_author_list_for_query
  10. from .arxiv_normalization import sanitize_arxiv_categories, sanitize_arxiv_id_list
  11. from ...utils.common import dedupe_strings_preserve_order
  12. from .venue_phrases import sanitize_venue_tokens
  13. if TYPE_CHECKING:
  14. from ...agents.support.search_models import SearchIntent
  15. def _search_intent_cls():
  16. from ...agents.support.search_models import SearchIntent
  17. return SearchIntent
  18. def strip_retrieval_meta_from_query(q: str) -> str:
  19. s = (q or "").strip()
  20. return " ".join(s.split()) if s else ""
  21. def _fence_inner_after_first_fence(t: str) -> str | None:
  22. first = t.find("```")
  23. if first < 0:
  24. return None
  25. rest = t[first + 3 :]
  26. rl = rest.lstrip()
  27. if rl[:4].lower() == "json":
  28. rl = rl[4:].lstrip(" \t\r\n")
  29. end = rl.find("```")
  30. body = rl[:end].strip() if end >= 0 else rl.strip()
  31. return body or None
  32. def extract_json_object(text: str) -> dict[str, Any | None]:
  33. t = (text or "").strip()
  34. if not t:
  35. return None
  36. if "```" in t and (boxed := _fence_inner_after_first_fence(t)):
  37. t = boxed
  38. start = t.find("{")
  39. end = t.rfind("}")
  40. if start < 0 or end <= start:
  41. return None
  42. raw = t[start : end + 1]
  43. raw = re.sub(r",\s*([\]}])", r"\1", raw)
  44. try:
  45. out = json.loads(raw)
  46. return out if isinstance(out, dict) else None
  47. except json.JSONDecodeError:
  48. return None
  49. _SORT_ALLOWED = frozenset({"relevance", "date"})
  50. _CONFIDENCE_ALLOWED = frozenset({"high", "medium", "low"})
  51. _SEARCH_STRATEGY_ALLOWED = frozenset(
  52. {"keyword_matching", "semantic_search", "hybrid", "targeted_lookup"}
  53. )
  54. def _intent_str_list_field(source: Any, cap: int) -> list[str]:
  55. if not isinstance(source, list):
  56. return []
  57. return [str(x).strip() for x in source if str(x).strip()][:cap]
  58. def _intent_str_or_list_field(source: Any, cap: int) -> list[str] | None:
  59. if isinstance(source, str) and source.strip():
  60. return [source.strip()][:cap]
  61. if isinstance(source, list):
  62. return [str(x).strip() for x in source if str(x).strip()][:cap]
  63. return None
  64. def _intent_year_field(v: Any) -> int | None:
  65. if isinstance(v, (int, float)) and 1900 <= int(v) <= 2100:
  66. return int(v)
  67. return None
  68. def _intent_bounded_int(v: Any, lo: int, hi: int) -> int | None:
  69. if isinstance(v, (int, float)):
  70. return max(lo, min(int(v), hi))
  71. return None
  72. def _intent_flag_merge(flags: dict[str, Any], d: dict[str, Any], key: str) -> Any:
  73. return flags[key] if key in flags else d.get(key)
  74. def _intent_use_tavily_raw(nested: bool, flags: dict[str, Any], d: dict[str, Any]) -> Any:
  75. if nested:
  76. return flags["use_tavily"] if "use_tavily" in flags else d.get("use_tavily")
  77. return d.get("use_tavily")
  78. def search_intent_from_dict(d: dict[str, Any]) -> SearchIntent:
  79. nested = isinstance(d.get("search"), dict)
  80. if nested:
  81. s = dict(d["search"])
  82. flags = dict(d["flags"]) if isinstance(d.get("flags"), dict) else {}
  83. rank = dict(d["ranking"]) if isinstance(d.get("ranking"), dict) else {}
  84. else:
  85. s, flags, rank = dict(d), {}, {}
  86. Si = _search_intent_cls()
  87. intent = Si()
  88. intent.query = str(s.get("query") or "").strip()[:500]
  89. intent.keywords = _intent_str_list_field(s.get("keywords") or [], 16)
  90. intent.authors = _intent_str_list_field(s.get("authors") or d.get("authors") or [], 8)
  91. intent.venues = _intent_str_list_field(s.get("venues") or [], 8)
  92. tt_raw = s.get("target_titles") or s.get("paper_titles") or s.get("paper_title") or []
  93. if (tt_parsed := _intent_str_or_list_field(tt_raw, 6)) is not None:
  94. intent.target_titles = tt_parsed
  95. ta_raw = s.get("target_authors") or s.get("target_author") or []
  96. if (ta_parsed := _intent_str_or_list_field(ta_raw, 6)) is not None:
  97. intent.target_authors = ta_parsed
  98. if (yf := _intent_year_field(s.get("year_from"))) is not None:
  99. intent.year_from = yf
  100. if (yt := _intent_year_field(s.get("year_to"))) is not None:
  101. intent.year_to = yt
  102. sort = str(s.get("sort") or "relevance").lower()
  103. intent.sort = sort if sort in _SORT_ALLOWED else "relevance"
  104. if (mr := _intent_bounded_int(s.get("max_results", 10), 5, 30)) is not None:
  105. intent.max_results = mr
  106. intent.arxiv_categories = _intent_str_list_field(s.get("arxiv_categories") or [], 12)
  107. axl_raw = s.get("arxiv_id_list") or s.get("pinned_arxiv_ids") or d.get("arxiv_id_list")
  108. if (ax_parsed := _intent_str_or_list_field(axl_raw, 8)) is not None:
  109. intent.arxiv_id_list = ax_parsed
  110. intent.is_short_acronym = bool(_intent_flag_merge(flags, d, "is_short_acronym"))
  111. intent.wants_classic = bool(_intent_flag_merge(flags, d, "wants_classic"))
  112. intent.wants_recent = bool(_intent_flag_merge(flags, d, "wants_recent"))
  113. intent.main_conference_proceedings_only = bool(
  114. _intent_flag_merge(flags, d, "main_conference_proceedings_only")
  115. )
  116. ut = _intent_use_tavily_raw(nested, flags, d)
  117. intent.use_tavily = None if ut is None else bool(ut)
  118. conf = str(_intent_flag_merge(flags, d, "confidence_level") or "").strip().lower()
  119. if conf in _CONFIDENCE_ALLOWED:
  120. intent.confidence_level = conf
  121. strat = str(_intent_flag_merge(flags, d, "search_strategy") or "").strip().lower()
  122. if strat in _SEARCH_STRATEGY_ALLOWED:
  123. intent.search_strategy = strat
  124. llm_sources = flags.get("sources") or d.get("sources") or []
  125. if isinstance(llm_sources, list) and llm_sources:
  126. allowed = {"arxiv", "dblp", "openalex", "tavily"}
  127. intent.sources = [str(src).strip().lower() for src in llm_sources if str(src).strip().lower() in allowed]
  128. rk_strat = str(flags.get("ranking_strategy") or d.get("ranking_strategy") or "").strip().lower()
  129. if rk_strat in ("date", "relevance", "hybrid"):
  130. intent.ranking_strategy = rk_strat
  131. use_llm = rank.get("use_llm_rank", rank.get("use_two_stage_rerank", d.get("use_llm_rank", d.get("use_two_stage_rerank", True))))
  132. intent.use_llm_rank = bool(use_llm)
  133. rc_raw = rank.get("rerank_recall_max", rank.get("rerank_coarse_top_n", d.get("rerank_recall_max", d.get("rerank_coarse_top_n", 24))))
  134. if (rc := _intent_bounded_int(rc_raw, 8, 60)) is not None:
  135. intent.rerank_recall_max = rc
  136. rat = rank.get("rationale", d.get("ranking_rationale"))
  137. if rat is not None:
  138. intent.ranking_rationale = str(rat).strip()[:800]
  139. return intent
  140. def finalize_llm_intent(intent: SearchIntent, profile: str) -> SearchIntent:
  141. intent.query = strip_retrieval_meta_from_query((intent.query or "")[:500])
  142. intent.keywords = dedupe_strings_preserve_order(list(intent.keywords or []), max_n=16)
  143. if not (intent.query or "").strip() and intent.keywords:
  144. intent.query = intent.keywords[0][:500]
  145. if not (intent.query or "").strip() and (intent.authors or []):
  146. intent.query = str((intent.authors or [])[0]).strip()[:500]
  147. from ...utils.author_query_match import normalize_author_names
  148. intent.authors = normalize_author_names(
  149. [str(x).strip() for x in (intent.authors or []) if str(x).strip()]
  150. )[:8]
  151. raw_venues = [str(x).strip() for x in (intent.venues or []) if str(x).strip()]
  152. intent.venues = sanitize_venue_tokens(raw_venues)[:8]
  153. intent.target_titles = [str(x).strip() for x in (intent.target_titles or []) if str(x).strip()][:6]
  154. from ..retrieval.method_acronym import is_method_acronym_token
  155. q_strip = (intent.query or "").strip()
  156. if intent.venues and is_method_acronym_token(q_strip):
  157. intent.keywords = [q_strip]
  158. intent.is_short_acronym = True
  159. intent.use_tavily = True
  160. intent.target_authors = [str(x).strip() for x in (intent.target_authors or []) if str(x).strip()][:6]
  161. intent.authors = _sanitize_author_list_for_query(intent.query or "", intent.authors)
  162. intent.max_results = max(5, min(int(intent.max_results or 10), 30))
  163. intent.rerank_recall_max = max(8, min(int(intent.rerank_recall_max or 24), 60))
  164. conf = str(intent.confidence_level or "").strip().lower()
  165. intent.confidence_level = conf if conf in ("high", "medium", "low") else "medium"
  166. strat = str(intent.search_strategy or "").strip().lower()
  167. intent.search_strategy = (
  168. strat
  169. if strat in ("keyword_matching", "semantic_search", "hybrid", "targeted_lookup")
  170. else "hybrid"
  171. )
  172. if profile == "novelty":
  173. intent.sort = "date"
  174. elif intent.sort not in ("relevance", "date"):
  175. intent.sort = "relevance"
  176. if bool(intent.wants_recent) and intent.sort != "date":
  177. intent.sort = "date"
  178. axl = [str(x).strip() for x in (intent.arxiv_id_list or []) if str(x).strip()][:8]
  179. intent.arxiv_id_list = axl
  180. if intent.year_from is not None and (intent.year_from < 1900 or intent.year_from > 2100):
  181. intent.year_from = None
  182. if intent.year_to is not None and (intent.year_to < 1900 or intent.year_to > 2100):
  183. intent.year_to = None
  184. _ensure_intent_year_window_ordered(intent)
  185. return intent
  186. def _ensure_intent_year_window_ordered(intent: SearchIntent) -> None:
  187. yf = getattr(intent, "year_from", None)
  188. yt = getattr(intent, "year_to", None)
  189. if isinstance(yf, int) and isinstance(yt, int) and yf > yt:
  190. intent.year_from, intent.year_to = yt, yf
  191. def infer_target_edition_year_for_recent(*, is_latest: bool = True, settings: Any | None = None) -> int:
  192. """Prompt hint for the latest likely conference edition year."""
  193. _ = is_latest, settings
  194. y_now = int(datetime.now().year)
  195. return max(1990, y_now - 1)
  196. def format_intent_llm_prompt(
  197. template: str,
  198. user_text: str,
  199. profile: str,
  200. *,
  201. correction_hint: str | None = None,
  202. ) -> str:
  203. now = datetime.now()
  204. edition_year = infer_target_edition_year_for_recent(is_latest=True)
  205. base = template.format(
  206. user_text=(user_text or "").strip()[:3500],
  207. profile=profile,
  208. current_date_iso=now.strftime("%Y-%m-%d"),
  209. current_year=now.year,
  210. suggested_edition_year=edition_year,
  211. )
  212. hint = (correction_hint or "").strip()
  213. if not hint:
  214. return base
  215. return (
  216. f"{base}\n\n"
  217. "## 修正要求(上次输出无效,请重新生成完整 JSON)\n"
  218. f"{hint}\n\n"
  219. "只输出一个 JSON 对象,不要 markdown 代码块或解释文字。"
  220. )
  221. def build_intent_retry_correction_hint(
  222. exc: BaseException,
  223. *,
  224. user_message: str,
  225. last_output: str | None = None,
  226. ) -> str:
  227. em = str(exc or "").strip()
  228. em_l = em.lower()
  229. msg = (user_message or "").strip()[:400]
  230. parts: list[str] = [f"用户查询:「{msg}」。"]
  231. if "empty_query" in em_l or "intent_parse_empty" in em_l:
  232. parts.append(
  233. "上次 JSON 缺少有效检索锚点:query、venues、authors、target_titles、arxiv_id_list 至少应有一项非空;"
  234. "若用户只提会议/最新,请把会议写入 venues,并按上文日期规则填写 year_from/year_to。"
  235. )
  236. elif "json" in em_l or "未返回有效" in em:
  237. parts.append("上次未返回可解析的 JSON 对象。")
  238. else:
  239. parts.append(f"上次解析失败:{em[:400]}。")
  240. out_snip = (last_output or "").strip()
  241. if out_snip:
  242. parts.append(f"上次模型输出片段(供对照,勿照抄错误):{out_snip[:600]}")
  243. return " ".join(parts)
  244. def apply_llm_intent_hygiene(intent: SearchIntent, _raw_user_text: str | None = None) -> None:
  245. """Clean parsed fields without changing venue/year semantics."""
  246. _ = _raw_user_text
  247. if intent is None:
  248. return
  249. intent.venues = sanitize_venue_tokens(list(intent.venues or []))[:8]
  250. intent.keywords = dedupe_strings_preserve_order(list(intent.keywords or []), max_n=16)
  251. intent.arxiv_categories = sanitize_arxiv_categories(list(intent.arxiv_categories or []))
  252. intent.arxiv_id_list = sanitize_arxiv_id_list(list(intent.arxiv_id_list or []))
  253. __all__ = [
  254. "extract_json_object",
  255. "search_intent_from_dict",
  256. "finalize_llm_intent",
  257. "apply_llm_intent_hygiene",
  258. "build_intent_retry_correction_hint",
  259. "format_intent_llm_prompt",
  260. "infer_target_edition_year_for_recent",
  261. "_ensure_intent_year_window_ordered",
  262. ]