search_intent.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. from __future__ import annotations
  2. import logging
  3. from datetime import datetime, timezone
  4. from pathlib import Path
  5. from typing import Any
  6. from content_agent.constants import RUNTIME_RECORD_SCHEMA_VERSION
  7. from content_agent.errors import ContentAgentError, ErrorCode
  8. from content_agent.integrations import config_store
  9. from content_agent.integrations.query_prompt_config import DEFAULT_PROFILE
  10. from content_agent.interfaces import QueryVariantClient, QueryVariantResult, RuntimeFileStore
  11. from content_agent.record_payload import with_raw_payload
  12. GENERIC_QUERIES = {
  13. "内容",
  14. "视频",
  15. "热门",
  16. "推荐",
  17. "短视频",
  18. "热门视频",
  19. "推荐视频",
  20. "热门内容",
  21. "推荐内容",
  22. "相关视频",
  23. "相关内容",
  24. "热" + "点视频",
  25. "热" + "点内容",
  26. }
  27. GENERIC_QUERY_TOKENS = (
  28. "短视频",
  29. "热门",
  30. "推荐",
  31. "相关",
  32. "热" + "点",
  33. "内容",
  34. "视频",
  35. "素材",
  36. "资料",
  37. "信息",
  38. "话题",
  39. )
  40. SEARCH_INTENT_POLICY_PATH = Path("tech_documents/数据接口与来源/search_intent_policy.json")
  41. DEFAULT_V4_QUERY_SEED_POINTS_LIMIT = 2
  42. def run(
  43. run_id: str,
  44. policy_run_id: str,
  45. pattern_seed_pack: dict[str, Any],
  46. runtime: RuntimeFileStore,
  47. query_variant_client: QueryVariantClient,
  48. *,
  49. strategy_version: str | None = None,
  50. platform: str = "",
  51. ) -> list[dict[str, Any]]:
  52. created_at = datetime.now(timezone.utc).isoformat()
  53. seed_terms = _terms(pattern_seed_pack.get("seed_terms"))
  54. if not seed_terms:
  55. raise _query_generation_error("seed_terms_empty")
  56. if str(strategy_version or "").upper() == "V4":
  57. return _run_v4(
  58. run_id,
  59. policy_run_id,
  60. pattern_seed_pack,
  61. runtime,
  62. created_at,
  63. seed_terms,
  64. query_variant_client,
  65. platform,
  66. )
  67. profile = getattr(query_variant_client, "profile", DEFAULT_PROFILE)
  68. variants_per_seed = int(profile.get("variants_per_seed", 1))
  69. if variants_per_seed != 1:
  70. raise _query_generation_error(
  71. "variants_per_seed_unsupported",
  72. {"variants_per_seed": variants_per_seed},
  73. )
  74. evidence_fields = profile.get("evidence_fields")
  75. generic_filter = profile.get("generic_filter")
  76. search_queries: list[dict[str, Any]] = []
  77. seen_queries: set[str] = set()
  78. for seed_index, seed_term in enumerate(seed_terms):
  79. item_query_id = f"q_{seed_index * 2 + 1:03d}"
  80. variant_query_id = f"q_{seed_index * 2 + 2:03d}"
  81. pattern_seed_ref = _pattern_seed_ref(pattern_seed_pack, seed_term, seed_index)
  82. item_single = _base_query_record(
  83. run_id=run_id,
  84. policy_run_id=policy_run_id,
  85. search_query_id=item_query_id,
  86. search_query=seed_term,
  87. generation_method="item_single",
  88. seed_term=seed_term,
  89. pattern_seed_ref=pattern_seed_ref,
  90. created_at=created_at,
  91. )
  92. _reserve_query(item_single, seen_queries, seed_term=seed_term, method="item_single")
  93. search_queries.append(item_single)
  94. evidence_context = _llm_input_evidence(
  95. pattern_seed_pack,
  96. seed_terms,
  97. seed_term,
  98. seed_index,
  99. sorted(seen_queries),
  100. evidence_fields=evidence_fields,
  101. )
  102. variant = _generate_variant(query_variant_client, seed_term, evidence_context)
  103. variant_query = _normalize_query(variant.query)
  104. _validate_variant_query(variant_query, seed_term, seen_queries, generic_filter=generic_filter)
  105. llm_variant = _base_query_record(
  106. run_id=run_id,
  107. policy_run_id=policy_run_id,
  108. search_query_id=variant_query_id,
  109. search_query=variant_query,
  110. generation_method="llm_variant",
  111. seed_term=seed_term,
  112. pattern_seed_ref=pattern_seed_ref,
  113. created_at=created_at,
  114. )
  115. llm_variant.update(
  116. {
  117. "llm_variant_of": item_query_id,
  118. "llm_input_evidence": variant.input_evidence,
  119. "llm_prompt_version": variant.prompt_version,
  120. "llm_generation_model": variant.model,
  121. }
  122. )
  123. _reserve_query(llm_variant, seen_queries, seed_term=seed_term, method="llm_variant")
  124. search_queries.append(llm_variant)
  125. expected_count = len(seed_terms) * 2
  126. if len(search_queries) != expected_count:
  127. raise _query_generation_error(
  128. "query_count_mismatch",
  129. {
  130. "seed_terms_count": len(seed_terms),
  131. "expected_query_count": expected_count,
  132. "actual_query_count": len(search_queries),
  133. },
  134. )
  135. search_queries = [with_raw_payload(row) for row in search_queries]
  136. runtime.append_jsonl(run_id, "search_queries.jsonl", search_queries)
  137. return search_queries
  138. def _run_v4(
  139. run_id: str,
  140. policy_run_id: str,
  141. pattern_seed_pack: dict[str, Any],
  142. runtime: RuntimeFileStore,
  143. created_at: str,
  144. seed_terms: list[str],
  145. query_variant_client: QueryVariantClient,
  146. platform: str,
  147. ) -> list[dict[str, Any]]:
  148. # 首轮搜索池三路并行:pattern seed_terms + 上游 query_seed_points + 分类叶子元素。
  149. seed_candidates = _v4_seed_candidates(pattern_seed_pack, seed_terms)
  150. query_seed_point_candidates = _v4_query_seed_point_candidates(pattern_seed_pack)
  151. category_candidates = _v4_category_element_candidates(pattern_seed_pack)
  152. if not seed_candidates and not query_seed_point_candidates and not category_candidates:
  153. raise _query_generation_error("v4_query_candidates_empty")
  154. # M9D Gate 2:仅非抖音,AI 判 query 是否易搜 50+;拿不准/异常从宽放过。
  155. apply_gate2 = bool(platform) and platform != "douyin"
  156. search_queries: list[dict[str, Any]] = []
  157. seen_queries: set[str] = set()
  158. def _admit(candidate_list: list[dict[str, Any]], *, use_gate2: bool, fallback: bool = False) -> None:
  159. for candidate in candidate_list:
  160. query = _normalize_query(candidate.get("text"))
  161. if not query:
  162. continue
  163. normalized_key = _normalize_query_key(query)
  164. if normalized_key in seen_queries:
  165. continue
  166. if use_gate2 and not _gate2_keep(query, query_variant_client):
  167. continue
  168. if fallback:
  169. candidate["source_ref"]["gate2_fallback"] = True
  170. seen_queries.add(normalized_key)
  171. query_id = f"q_{len(search_queries) + 1:03d}"
  172. row = _base_query_record(
  173. run_id=run_id,
  174. policy_run_id=policy_run_id,
  175. search_query_id=query_id,
  176. search_query=query,
  177. generation_method=candidate["generation_method"],
  178. seed_term=query,
  179. pattern_seed_ref=_v4_pattern_seed_ref(pattern_seed_pack, candidate, query),
  180. created_at=created_at,
  181. query_source_terms=[query],
  182. query_source_fields=candidate["query_source_fields"],
  183. )
  184. row["query_source_refs"] = [_v4_query_source_ref(candidate, query)]
  185. search_queries.append(row)
  186. # seed_terms 和分类叶子元素是需求侧必搜来源;query_seed_points 仍保留非抖音 Gate2。
  187. _admit(seed_candidates, use_gate2=False)
  188. _admit(query_seed_point_candidates, use_gate2=apply_gate2)
  189. _admit(category_candidates, use_gate2=False)
  190. if not search_queries:
  191. raise _query_generation_error("v4_search_queries_empty")
  192. search_queries = [with_raw_payload(row) for row in search_queries]
  193. runtime.append_jsonl(run_id, "search_queries.jsonl", search_queries)
  194. return search_queries
  195. def _v4_seed_candidates(
  196. pattern_seed_pack: dict[str, Any],
  197. seed_terms: list[str],
  198. ) -> list[dict[str, Any]]:
  199. return [
  200. {
  201. "text": seed_term,
  202. "query_source_type": "seed_term",
  203. "generation_method": "seed_term",
  204. "query_source_fields": ["seed_terms"],
  205. "rank": index + 1,
  206. "source_ref": {
  207. "source_field": "seed_terms",
  208. "source_index": index,
  209. "seed_term": seed_term,
  210. "query_source_ref_id": f"seed_terms:{index}",
  211. },
  212. }
  213. for index, seed_term in enumerate(seed_terms)
  214. ]
  215. def _v4_query_seed_point_candidates(pattern_seed_pack: dict[str, Any]) -> list[dict[str, Any]]:
  216. """首轮搜索池:吃上游 query_seed_points(已按覆盖度排序的灵感/目的点),按 rank 升序。
  217. 目的点剥动作词前缀(复用 _cleanup_purpose_point),原始 point_text 存进 source_ref 不覆盖。"""
  218. points = pattern_seed_pack.get("query_seed_points")
  219. if not isinstance(points, list):
  220. return []
  221. ordered = sorted(
  222. (point for point in points if isinstance(point, dict)),
  223. key=lambda point: point.get("rank") if isinstance(point.get("rank"), int) else 1_000_000,
  224. )
  225. limit = _v4_query_seed_points_limit()
  226. if limit is not None:
  227. ordered = ordered[:limit]
  228. candidates: list[dict[str, Any]] = []
  229. for point in ordered:
  230. point_type = str(point.get("point_type") or "").strip()
  231. if point_type not in {"目的点", "灵感点"}:
  232. continue
  233. point_text = _normalize_query(point.get("point_text"))
  234. if not point_text:
  235. continue
  236. text = _cleanup_purpose_point(point_text) if point_type == "目的点" else point_text
  237. if not text or _is_generic_query(text):
  238. continue
  239. rank = len(candidates) + 1
  240. post_id = point.get("post_id")
  241. point_id = point.get("id") or point.get("topic_point_id")
  242. candidates.append(
  243. {
  244. "text": text,
  245. "query_source_type": "query_seed_point",
  246. "generation_method": "query_seed_point",
  247. "query_source_fields": ["query_seed_points.point_text"],
  248. "rank": rank,
  249. "source_ref": {
  250. "query_source_ref_id": f"qsp:post:{post_id or 'unknown_post'}#point:{point_id or rank}",
  251. "point_type": point_type,
  252. "point_text": point_text,
  253. "cleaned_text": text,
  254. "coverage_post_count": point.get("coverage_post_count"),
  255. "qsp_rank": point.get("rank"),
  256. "post_id": post_id,
  257. "category_id": point.get("category_id"),
  258. "element_id": point.get("source_element_id"),
  259. },
  260. }
  261. )
  262. return candidates
  263. def _v4_query_seed_points_limit() -> int | None:
  264. try:
  265. config, _raw = config_store.load_json(SEARCH_INTENT_POLICY_PATH)
  266. except FileNotFoundError:
  267. return DEFAULT_V4_QUERY_SEED_POINTS_LIMIT
  268. value = config.get("v4_query_seed_points_limit") if isinstance(config, dict) else None
  269. if value is None:
  270. return DEFAULT_V4_QUERY_SEED_POINTS_LIMIT
  271. try:
  272. parsed = int(value)
  273. except (TypeError, ValueError):
  274. return DEFAULT_V4_QUERY_SEED_POINTS_LIMIT
  275. return parsed if parsed >= 0 else DEFAULT_V4_QUERY_SEED_POINTS_LIMIT
  276. def _v4_category_element_candidates(pattern_seed_pack: dict[str, Any]) -> list[dict[str, Any]]:
  277. candidates: list[dict[str, Any]] = []
  278. for binding_index, binding in enumerate(_bindings(pattern_seed_pack), start=1):
  279. for element_index, sample in enumerate(_sample_elements(binding), start=1):
  280. name = _normalize_query(sample.get("name"))
  281. if not name or _is_generic_query(name):
  282. continue
  283. rank = len(candidates) + 1
  284. category_id = sample.get("category_id") or binding.get("category_id")
  285. element_id = sample.get("source_element_id") or sample.get("id")
  286. source_ref = {
  287. "query_source_ref_id": _element_source_ref_id(category_id, element_id, name, rank),
  288. "binding_rank": binding_index,
  289. "element_rank": element_index,
  290. "element_name": name,
  291. "element_id": element_id,
  292. "category_id": category_id,
  293. "itemset_item_id": binding.get("itemset_item_id"),
  294. "post_id": sample.get("post_id"),
  295. "point_type": sample.get("point_type"),
  296. }
  297. candidates.append(
  298. {
  299. "text": name,
  300. "query_source_type": "category_terminal_element",
  301. "generation_method": "category_leaf_element",
  302. "query_source_fields": ["element_bindings.sample_elements.name"],
  303. "rank": rank,
  304. "source_ref": source_ref,
  305. }
  306. )
  307. return candidates
  308. def _bindings(pattern_seed_pack: dict[str, Any]) -> list[dict[str, Any]]:
  309. values = pattern_seed_pack.get("element_bindings")
  310. return [item for item in values if isinstance(item, dict)] if isinstance(values, list) else []
  311. def _sample_elements(binding: dict[str, Any]) -> list[dict[str, Any]]:
  312. values = binding.get("sample_elements")
  313. return [item for item in values if isinstance(item, dict)] if isinstance(values, list) else []
  314. def _element_source_ref_id(category_id: Any, element_id: Any, name: str, rank: int) -> str:
  315. category = category_id or "unknown_category"
  316. element = element_id or name or rank
  317. return f"category:{category}#element:{element}"
  318. _LOG = logging.getLogger(__name__)
  319. _PURPOSE_PREFIX_PATH = Path("product_documents/配置/purpose_point_prefixes.v1.json")
  320. def _purpose_point_prefixes() -> tuple[str, ...]:
  321. """目的点要剥的动作词前缀:**全部来自 JSON 配置**(config_store 按 mtime 缓存),代码不写死任何前缀表。
  322. 以后加词只改 `product_documents/配置/purpose_point_prefixes.v1.json`。
  323. 读不到/为空 → 不剥前缀并**告警**(而非静默退回旧表,免得配置没生效却看着像正常)。"""
  324. try:
  325. data, _ = config_store.load_json(_PURPOSE_PREFIX_PATH)
  326. prefixes = data.get("prefixes") if isinstance(data, dict) else None
  327. if isinstance(prefixes, list):
  328. cleaned = tuple(str(p) for p in prefixes if p)
  329. if cleaned:
  330. return cleaned
  331. except (FileNotFoundError, OSError, ValueError) as exc:
  332. _LOG.warning("目的点前缀配置读取失败(%s),本次不剥前缀:%s", _PURPOSE_PREFIX_PATH, exc)
  333. return ()
  334. _LOG.warning("目的点前缀配置缺 prefixes 或为空(%s),本次不剥前缀", _PURPOSE_PREFIX_PATH)
  335. return ()
  336. def _cleanup_purpose_point(value: str) -> str:
  337. text = _normalize_query(value)
  338. for prefix in _purpose_point_prefixes():
  339. if text.startswith(prefix) and len(text) > len(prefix):
  340. text = text[len(prefix):].strip(" ,,。::")
  341. break
  342. return _normalize_query(text)
  343. def _v4_pattern_seed_ref(
  344. pattern_seed_pack: dict[str, Any],
  345. candidate: dict[str, Any],
  346. query: str,
  347. ) -> dict[str, Any]:
  348. source_ref = dict(candidate.get("source_ref") or {})
  349. return {
  350. "query_source_type": candidate["query_source_type"],
  351. "query_source_ref_id": source_ref.get("query_source_ref_id"),
  352. "query_source_text": query,
  353. "query_source_rank": candidate["rank"],
  354. "pattern_execution_id": pattern_seed_pack.get("pattern_execution_id"),
  355. "mining_config_id": pattern_seed_pack.get("mining_config_id"),
  356. "source_post_id": pattern_seed_pack.get("source_post_id"),
  357. "matched_post_ids": pattern_seed_pack.get("matched_post_ids") or [],
  358. "itemset_ids": _itemset_ids(pattern_seed_pack),
  359. "source_ref": source_ref,
  360. }
  361. def _v4_query_source_ref(candidate: dict[str, Any], query: str) -> dict[str, Any]:
  362. source_ref = dict(candidate.get("source_ref") or {})
  363. return {
  364. "query_source_type": candidate["query_source_type"],
  365. "query_source_ref_id": source_ref.get("query_source_ref_id"),
  366. "query_source_text": query,
  367. "query_source_rank": candidate["rank"],
  368. "generation_method": candidate["generation_method"],
  369. "source_ref": source_ref,
  370. }
  371. def _terms(values: Any) -> list[str]:
  372. if not isinstance(values, list):
  373. return []
  374. unique: list[str] = []
  375. seen: set[str] = set()
  376. for value in values:
  377. if not isinstance(value, str):
  378. continue
  379. term = " ".join(value.split()).strip()
  380. if not term or term in seen:
  381. continue
  382. seen.add(term)
  383. unique.append(term)
  384. return unique
  385. def _base_query_record(
  386. *,
  387. run_id: str,
  388. policy_run_id: str,
  389. search_query_id: str,
  390. search_query: str,
  391. generation_method: str,
  392. seed_term: str,
  393. pattern_seed_ref: dict[str, Any],
  394. created_at: str,
  395. query_source_terms: list[str] | None = None,
  396. query_source_fields: list[str] | None = None,
  397. ) -> dict[str, Any]:
  398. return {
  399. "record_schema_version": RUNTIME_RECORD_SCHEMA_VERSION,
  400. "run_id": run_id,
  401. "policy_run_id": policy_run_id,
  402. "search_query_id": search_query_id,
  403. "search_query": search_query,
  404. "search_query_generation_method": generation_method,
  405. "discovery_start_source": "pattern_itemset",
  406. "previous_discovery_step": "pattern_search_query",
  407. "search_query_effect_status": "pending",
  408. "query_source_terms": query_source_terms or [seed_term],
  409. "query_source_fields": query_source_fields or ["seed_terms"],
  410. "pattern_seed_ref": pattern_seed_ref,
  411. "created_at": created_at,
  412. }
  413. def _generate_variant(
  414. query_variant_client: QueryVariantClient,
  415. seed_term: str,
  416. evidence_context: dict[str, Any],
  417. ) -> QueryVariantResult:
  418. try:
  419. result = query_variant_client.generate_variant(
  420. seed_term=seed_term,
  421. evidence_context=evidence_context,
  422. )
  423. except ContentAgentError:
  424. raise
  425. except Exception as exc:
  426. raise _query_generation_error(
  427. "llm_variant_exception",
  428. {
  429. "seed_term": seed_term,
  430. "exception_type": type(exc).__name__,
  431. },
  432. ) from exc
  433. if not isinstance(result, QueryVariantResult):
  434. raise _query_generation_error(
  435. "llm_variant_result_invalid",
  436. {
  437. "seed_term": seed_term,
  438. "result_type": type(result).__name__,
  439. },
  440. )
  441. return result
  442. def _validate_variant_query(
  443. query: str,
  444. seed_term: str,
  445. seen_queries: set[str],
  446. *,
  447. generic_filter: dict[str, Any] | None = None,
  448. ) -> None:
  449. if not query:
  450. raise _query_generation_error("llm_variant_empty", {"seed_term": seed_term})
  451. if query == seed_term:
  452. raise _query_generation_error("llm_variant_same_as_seed", {"seed_term": seed_term})
  453. if query in seen_queries:
  454. raise _query_generation_error(
  455. "llm_variant_duplicate",
  456. {
  457. "seed_term": seed_term,
  458. "search_query": query,
  459. },
  460. )
  461. if _is_generic_query(query, generic_filter=generic_filter):
  462. raise _query_generation_error(
  463. "llm_variant_generic",
  464. {
  465. "seed_term": seed_term,
  466. "search_query": query,
  467. },
  468. )
  469. def _reserve_query(
  470. row: dict[str, Any],
  471. seen_queries: set[str],
  472. *,
  473. seed_term: str,
  474. method: str,
  475. ) -> None:
  476. query = _normalize_query(row.get("search_query", ""))
  477. if not query:
  478. raise _query_generation_error(
  479. "search_query_empty",
  480. {
  481. "seed_term": seed_term,
  482. "search_query_generation_method": method,
  483. },
  484. )
  485. if query in seen_queries:
  486. raise _query_generation_error(
  487. "search_query_duplicate",
  488. {
  489. "seed_term": seed_term,
  490. "search_query": query,
  491. "search_query_generation_method": method,
  492. },
  493. )
  494. row["search_query"] = query
  495. seen_queries.add(query)
  496. def _pattern_seed_ref(
  497. pattern_seed_pack: dict[str, Any],
  498. seed_term: str,
  499. seed_index: int,
  500. ) -> dict[str, Any]:
  501. return {
  502. "source_field": "seed_terms",
  503. "source_index": seed_index,
  504. "seed_term": seed_term,
  505. "pattern_execution_id": pattern_seed_pack.get("pattern_execution_id"),
  506. "mining_config_id": pattern_seed_pack.get("mining_config_id"),
  507. "source_post_id": pattern_seed_pack.get("source_post_id"),
  508. "matched_post_ids": pattern_seed_pack.get("matched_post_ids") or [],
  509. "itemset_ids": _itemset_ids(pattern_seed_pack),
  510. }
  511. def _llm_input_evidence(
  512. pattern_seed_pack: dict[str, Any],
  513. seed_terms: list[str],
  514. seed_term: str,
  515. seed_index: int,
  516. existing_search_queries: list[str],
  517. evidence_fields: list[str] | None = None,
  518. ) -> dict[str, Any]:
  519. evidence = {
  520. "seed_term": seed_term,
  521. "seed_terms": seed_terms,
  522. "existing_search_queries": existing_search_queries,
  523. "source_field": "seed_terms",
  524. "source_index": seed_index,
  525. "itemset_items": pattern_seed_pack.get("itemset_items")
  526. or pattern_seed_pack.get("itemsets")
  527. or [],
  528. "category_bindings": pattern_seed_pack.get("category_bindings") or [],
  529. "element_bindings": pattern_seed_pack.get("element_bindings") or [],
  530. "pattern_source_system": pattern_seed_pack.get("pattern_source_system"),
  531. "pattern_execution_id": pattern_seed_pack.get("pattern_execution_id"),
  532. "mining_config_id": pattern_seed_pack.get("mining_config_id"),
  533. "source_post_id": pattern_seed_pack.get("source_post_id"),
  534. "matched_post_ids": pattern_seed_pack.get("matched_post_ids") or [],
  535. "itemset_ids": _itemset_ids(pattern_seed_pack),
  536. "support": pattern_seed_pack.get("support"),
  537. "absolute_support": pattern_seed_pack.get("absolute_support"),
  538. "confidence": pattern_seed_pack.get("confidence"),
  539. }
  540. if evidence_fields is None:
  541. return evidence
  542. return {field: evidence[field] for field in evidence_fields if field in evidence}
  543. def _itemset_ids(pattern_seed_pack: dict[str, Any]) -> list[Any]:
  544. direct = pattern_seed_pack.get("itemset_ids")
  545. if isinstance(direct, list):
  546. return direct
  547. itemsets = pattern_seed_pack.get("itemsets")
  548. if not isinstance(itemsets, list):
  549. return []
  550. ids: list[Any] = []
  551. for itemset in itemsets:
  552. if not isinstance(itemset, dict):
  553. continue
  554. itemset_id = itemset.get("itemset_id")
  555. if itemset_id is not None:
  556. ids.append(itemset_id)
  557. return ids
  558. def _normalize_query(value: Any) -> str:
  559. if not isinstance(value, str):
  560. return ""
  561. return " ".join(value.split()).strip()
  562. def _normalize_query_key(value: Any) -> str:
  563. return _normalize_query(value).casefold()
  564. def _is_generic_query(query: str, generic_filter: dict[str, Any] | None = None) -> bool:
  565. generic_queries = set((generic_filter or {}).get("queries") or GENERIC_QUERIES)
  566. generic_tokens = tuple((generic_filter or {}).get("tokens") or GENERIC_QUERY_TOKENS)
  567. compact = "".join(query.split())
  568. if not compact or len(compact) <= 1:
  569. return True
  570. if not any(char.isalnum() for char in compact):
  571. return True
  572. if compact in generic_queries:
  573. return True
  574. remainder = compact
  575. for token in generic_tokens:
  576. remainder = remainder.replace(token, "")
  577. return not remainder
  578. def _gate2_keep(query_text: str, query_variant_client: QueryVariantClient) -> bool:
  579. """M9D Gate 2:True=保留;client 无 judge 方法(mock)/异常 → 从宽保留。"""
  580. judge = getattr(query_variant_client, "judge_query_fifty_plus", None)
  581. if not callable(judge):
  582. return True
  583. try:
  584. return bool(judge(query_text))
  585. except Exception:
  586. return True
  587. def _query_generation_error(
  588. reason: str,
  589. detail: dict[str, Any] | None = None,
  590. ) -> ContentAgentError:
  591. return ContentAgentError(
  592. ErrorCode.QUERY_GENERATION_FAILED,
  593. "query generation failed",
  594. {
  595. "reason": reason,
  596. **(detail or {}),
  597. },
  598. )