evidence_pack_builder.py 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302
  1. """Build DB-validated evidence packs for DemandAgent outputs."""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import re
  6. from collections import defaultdict
  7. from collections.abc import Iterable, Mapping
  8. from typing import Any
  9. from examples.demand.db_manager import (
  10. query_case_ids_by_post_ids,
  11. query_element_bindings_for_items,
  12. query_execution_for_evidence,
  13. query_itemset_evidence,
  14. query_itemset_items_with_categories,
  15. query_seed_points_for_sources,
  16. query_source_elements,
  17. )
  18. from examples.demand.pg_pattern_repository import DEFAULT_DEMAND_PLATFORM, normalize_platform
  19. SOURCE_KIND_PATTERN_ITEMSET = "pattern_itemset"
  20. SOURCE_KIND_HIGH_WEIGHT_ELEMENT = "high_weight_element"
  21. SOURCE_KIND_HIGH_WEIGHT_CATEGORY = "high_weight_category"
  22. SOURCE_KIND_ELEMENT_CO_OCCURRENCE = "element_co_occurrence"
  23. SOURCE_KIND_CATEGORY_CO_OCCURRENCE = "category_co_occurrence"
  24. SOURCE_KIND_MULTI_SOURCE = "multi_source"
  25. SUPPORTED_SOURCE_KINDS = {
  26. SOURCE_KIND_PATTERN_ITEMSET,
  27. SOURCE_KIND_HIGH_WEIGHT_ELEMENT,
  28. SOURCE_KIND_HIGH_WEIGHT_CATEGORY,
  29. SOURCE_KIND_ELEMENT_CO_OCCURRENCE,
  30. SOURCE_KIND_CATEGORY_CO_OCCURRENCE,
  31. }
  32. PATTERN_SOURCE_SYSTEM = "pg_pattern_v2"
  33. PATTERN_ITEMSET_SCOPE = "topic"
  34. def build_evidence_pack(
  35. execution_id: int,
  36. demand_item: Any,
  37. trace_id: str,
  38. demand_task_id: int | None,
  39. demand_content_id: int | None,
  40. *,
  41. demand_scope: Mapping[str, Any] | None = None,
  42. merge_leve2: str | None = None,
  43. platform: str | None = None,
  44. ) -> dict[str, Any]:
  45. """Return a DB-validated evidence pack or a reject result.
  46. This function is intentionally read-only. It never writes Demand rows or
  47. reject rows; callers can route the returned result into their own sink.
  48. """
  49. try:
  50. return _build_evidence_pack(
  51. execution_id=execution_id,
  52. demand_item=demand_item,
  53. trace_id=trace_id,
  54. demand_task_id=demand_task_id,
  55. demand_content_id=demand_content_id,
  56. demand_scope=demand_scope,
  57. merge_leve2=merge_leve2,
  58. platform=platform,
  59. )
  60. except Exception as exc:
  61. return _reject(f"db evidence validation failed: {exc}")
  62. def _build_evidence_pack(
  63. execution_id: int,
  64. demand_item: Any,
  65. trace_id: str,
  66. demand_task_id: int | None,
  67. demand_content_id: int | None,
  68. *,
  69. demand_scope: Mapping[str, Any] | None = None,
  70. merge_leve2: str | None = None,
  71. platform: str | None = None,
  72. ) -> dict[str, Any]:
  73. evidence_refs = _extract_evidence_refs(demand_item)
  74. resolved_scope = _resolve_demand_scope(
  75. execution_id=int(execution_id),
  76. evidence_refs=evidence_refs,
  77. demand_item=demand_item,
  78. demand_scope=demand_scope,
  79. merge_leve2=merge_leve2,
  80. platform=platform,
  81. )
  82. scope_merge_leve2 = _clean_str(resolved_scope.get("merge_leve2"))
  83. scope_platform = _clean_str(resolved_scope.get("platform"))
  84. execution = query_execution_for_evidence(int(execution_id))
  85. if not execution:
  86. return _reject(f"execution_id {execution_id} not found")
  87. execution_status = _clean_str(execution.get("status")).lower()
  88. if execution_status != "success":
  89. return _reject(
  90. f"execution_id {execution_id} status is {execution.get('status')}, not success"
  91. )
  92. source_candidates = _extract_evidence_sources(evidence_refs, demand_item)
  93. if not source_candidates:
  94. return _reject("missing evidence_refs.sources or resolvable evidence source")
  95. resolved_sources: list[dict[str, Any]] = []
  96. reject_reasons: list[str] = []
  97. for source in source_candidates:
  98. source_kind = _resolve_source_kind(source, demand_item, [])
  99. if source_kind == SOURCE_KIND_PATTERN_ITEMSET:
  100. resolved, reason = _resolve_pattern_itemset_source(
  101. execution_id=int(execution_id),
  102. source=source,
  103. demand_item=demand_item,
  104. evidence_refs=evidence_refs,
  105. scope_merge_leve2=scope_merge_leve2 or None,
  106. scope_platform=scope_platform or None,
  107. )
  108. else:
  109. resolved, reason = _resolve_elementish_source(
  110. execution_id=int(execution_id),
  111. source=source,
  112. demand_item=demand_item,
  113. source_kind=source_kind,
  114. scope_merge_leve2=scope_merge_leve2 or None,
  115. scope_platform=scope_platform or None,
  116. )
  117. if resolved:
  118. resolved_sources.append(resolved)
  119. elif reason:
  120. reject_reasons.append(reason)
  121. if not resolved_sources:
  122. return _reject("no evidence source passed DB validation: " + "; ".join(reject_reasons[:5]))
  123. matched_post_ids = _merge_post_ids(source.get("matched_post_ids") for source in resolved_sources)
  124. if not matched_post_ids:
  125. return _reject("validated evidence sources produced no matched_post_ids")
  126. validated_source_post_ids = _unique_strings(source.get("source_post_id") for source in resolved_sources)
  127. source_post_id = next((post_id for post_id in validated_source_post_ids if post_id in set(matched_post_ids)), "")
  128. if not source_post_id:
  129. source_post_id = _choose_source_post_id(evidence_refs, demand_item, matched_post_ids)
  130. if not source_post_id:
  131. source_post_id = matched_post_ids[0]
  132. if source_post_id not in set(matched_post_ids):
  133. return _reject(f"source_post_id {source_post_id} is not in validated matched_post_ids")
  134. itemset_ids = _unique_ints(
  135. itemset_id
  136. for source in resolved_sources
  137. for itemset_id in source.get("itemset_ids", [])
  138. )
  139. itemset_items = _dedupe_itemset_items(
  140. item
  141. for source in resolved_sources
  142. for item in source.get("raw_itemset_items", [])
  143. )
  144. category_bindings = _dedupe_bindings(
  145. binding
  146. for source in resolved_sources
  147. for binding in source.get("category_bindings", [])
  148. )
  149. element_bindings = _dedupe_bindings(
  150. binding
  151. for source in resolved_sources
  152. for binding in source.get("element_bindings", [])
  153. )
  154. if not category_bindings:
  155. return _reject("validated evidence sources produced no category_bindings")
  156. if not element_bindings:
  157. return _reject("validated evidence sources produced no element_bindings")
  158. seed_terms = _build_seed_terms_from_sources(demand_item, resolved_sources, itemset_items, element_bindings)
  159. if not seed_terms:
  160. return _reject("seed_terms cannot be resolved from DB-validated evidence sources")
  161. case_rows = query_case_ids_by_post_ids(matched_post_ids)
  162. decode_case_ids = _unique_strings(row.get("case_id") for row in case_rows)
  163. query_seed_points_top_k = min(max(_env_int("DEMAND_QUERY_SEED_POINTS_TOP_K", 30), 0), 30)
  164. query_seed_points = query_seed_points_for_sources(
  165. execution_id=int(execution_id),
  166. matched_post_ids=matched_post_ids,
  167. category_ids=_unique_ints(binding.get("category_id") for binding in category_bindings),
  168. top_k=query_seed_points_top_k,
  169. )
  170. mining_config_ids = _unique_ints(
  171. config_id
  172. for source in resolved_sources
  173. for config_id in source.get("mining_config_ids", [])
  174. )
  175. absolute_support = _resolve_absolute_support(execution, resolved_sources, matched_post_ids)
  176. source_kinds = _unique_strings(source.get("source_kind") for source in resolved_sources)
  177. source_kind = source_kinds[0] if len(source_kinds) == 1 else SOURCE_KIND_MULTI_SOURCE
  178. evidence_pack = {
  179. "pattern_source_system": PATTERN_SOURCE_SYSTEM,
  180. "pattern_execution_id": int(execution_id),
  181. "mining_config_id": mining_config_ids[0] if mining_config_ids else None,
  182. "source_kind": source_kind,
  183. "case_id_type": "post_id",
  184. "source_post_id": source_post_id,
  185. "evidence_sources": _build_evidence_sources_payload(resolved_sources),
  186. "category_bindings": category_bindings,
  187. "element_bindings": element_bindings,
  188. "itemset_ids": itemset_ids,
  189. "itemset_items": _build_itemset_items(itemset_items),
  190. "support": _resolve_support(execution, resolved_sources, matched_post_ids),
  191. "absolute_support": absolute_support,
  192. "filtered_absolute_support": len(matched_post_ids),
  193. "scoped_post_count": len(matched_post_ids),
  194. "matched_post_ids": matched_post_ids,
  195. "video_ids": matched_post_ids,
  196. "case_ids": matched_post_ids,
  197. "decode_case_ids": decode_case_ids,
  198. "seed_terms": seed_terms,
  199. "query_seed_points": query_seed_points,
  200. "demand_scope": resolved_scope,
  201. "trace_id": trace_id,
  202. "demand_task_id": demand_task_id,
  203. "demand_content_id": demand_content_id,
  204. "source_certainty": "db_validated",
  205. "validation_status": "passed",
  206. }
  207. return {"success": True, "evidence_pack": evidence_pack}
  208. def _reject(reason: str) -> dict[str, Any]:
  209. return {"success": False, "reject_reason": reason}
  210. def _extract_evidence_sources(evidence_refs: Mapping[str, Any], demand_item: Any) -> list[dict[str, Any]]:
  211. raw_sources = evidence_refs.get("sources")
  212. sources: list[dict[str, Any]] = []
  213. if isinstance(raw_sources, list):
  214. for raw_source in raw_sources:
  215. if isinstance(raw_source, Mapping):
  216. sources.append(dict(raw_source))
  217. elif isinstance(raw_sources, Mapping):
  218. sources.append(dict(raw_sources))
  219. if not sources and evidence_refs:
  220. sources.append(dict(evidence_refs))
  221. if not sources:
  222. names = _normalize_string_list(_read_field(demand_item, "element_names"))
  223. if names:
  224. sources.append(
  225. {
  226. "source_kind": SOURCE_KIND_HIGH_WEIGHT_ELEMENT,
  227. "source_tool": "create_demand_items.element_names_fallback",
  228. "element_names": names,
  229. }
  230. )
  231. return sources
  232. def _resolve_pattern_itemset_source(
  233. *,
  234. execution_id: int,
  235. source: Mapping[str, Any],
  236. demand_item: Any,
  237. evidence_refs: Mapping[str, Any],
  238. scope_merge_leve2: str | None,
  239. scope_platform: str | None,
  240. ) -> tuple[dict[str, Any] | None, str | None]:
  241. itemset_ids, invalid_itemset_ids = _normalize_int_list(
  242. _first_present(
  243. source.get("itemset_ids"),
  244. source.get("itemset_id"),
  245. evidence_refs.get("itemset_ids"),
  246. evidence_refs.get("itemset_id"),
  247. _read_field(demand_item, "itemset_ids"),
  248. _read_field(demand_item, "itemset_id"),
  249. )
  250. )
  251. if invalid_itemset_ids:
  252. return None, f"invalid itemset_ids: {invalid_itemset_ids}"
  253. if not itemset_ids:
  254. itemset_ids = _extract_itemset_ids_from_text(
  255. source.get("reason"),
  256. source.get("desc"),
  257. source.get("description"),
  258. _read_field(demand_item, "reason"),
  259. _read_field(demand_item, "desc"),
  260. )
  261. if not itemset_ids:
  262. return None, "pattern_itemset source missing itemset_ids"
  263. itemsets = query_itemset_evidence(
  264. execution_id=execution_id,
  265. itemset_ids=itemset_ids,
  266. merge_leve2=scope_merge_leve2,
  267. platform=scope_platform,
  268. )
  269. found_itemset_ids = {int(itemset["id"]) for itemset in itemsets}
  270. missing_itemset_ids = [itemset_id for itemset_id in itemset_ids if itemset_id not in found_itemset_ids]
  271. if missing_itemset_ids:
  272. return None, f"itemset_id {missing_itemset_ids[0]} not found under execution_id {execution_id}"
  273. invalid_itemset_reason = _validate_itemset_facts(itemsets)
  274. if invalid_itemset_reason:
  275. return None, invalid_itemset_reason
  276. itemset_items = query_itemset_items_with_categories(
  277. execution_id=execution_id,
  278. itemset_ids=itemset_ids,
  279. )
  280. items_by_itemset: dict[int, list[dict[str, Any]]] = defaultdict(list)
  281. for item in itemset_items:
  282. items_by_itemset[int(item["itemset_id"])].append(item)
  283. item_reason = _validate_itemset_items(
  284. execution_id=execution_id,
  285. itemsets=itemsets,
  286. items_by_itemset=items_by_itemset,
  287. )
  288. if item_reason:
  289. return None, item_reason
  290. matched_post_ids = _merge_post_ids(itemset.get("matched_post_ids") for itemset in itemsets)
  291. requested_source_post_id = _choose_source_post_id(source, demand_item, matched_post_ids)
  292. if not requested_source_post_id:
  293. return None, "source_post_id cannot be resolved from DB-validated itemset posts"
  294. if requested_source_post_id not in set(matched_post_ids):
  295. return None, (
  296. f"source_post_id {requested_source_post_id} is not in matched_post_ids "
  297. f"for itemset_ids={itemset_ids}"
  298. )
  299. element_bindings = query_element_bindings_for_items(
  300. execution_id=execution_id,
  301. itemset_items=itemset_items,
  302. post_ids=[requested_source_post_id],
  303. )
  304. binding_reason = _validate_element_bindings(itemset_items, element_bindings)
  305. if binding_reason:
  306. source_post_id, element_bindings, binding_reason = _resolve_source_post_with_bindings(
  307. execution_id=execution_id,
  308. itemset_items=itemset_items,
  309. matched_post_ids=matched_post_ids,
  310. preferred_post_id=requested_source_post_id,
  311. )
  312. if binding_reason:
  313. return None, binding_reason
  314. else:
  315. source_post_id = requested_source_post_id
  316. return {
  317. "source_kind": SOURCE_KIND_PATTERN_ITEMSET,
  318. "source_tool": _clean_str(source.get("source_tool")) or "get_itemset_detail",
  319. "itemset_ids": itemset_ids,
  320. "mining_config_ids": _unique_ints(itemset.get("mining_config_id") for itemset in itemsets),
  321. "support": min(float(itemset["support"]) for itemset in itemsets),
  322. "absolute_support": min(int(itemset["absolute_support"]) for itemset in itemsets),
  323. "matched_post_ids": matched_post_ids,
  324. "source_post_id": source_post_id,
  325. "raw_itemset_items": itemset_items,
  326. "category_bindings": _build_category_bindings(itemset_items),
  327. "element_bindings": _build_element_bindings(element_bindings),
  328. "seed_terms": _build_seed_terms_from_itemset_items(itemset_items),
  329. }, None
  330. def _resolve_elementish_source(
  331. *,
  332. execution_id: int,
  333. source: Mapping[str, Any],
  334. demand_item: Any,
  335. source_kind: str,
  336. scope_merge_leve2: str | None,
  337. scope_platform: str | None,
  338. ) -> tuple[dict[str, Any] | None, str | None]:
  339. if source_kind not in SUPPORTED_SOURCE_KINDS or source_kind == SOURCE_KIND_PATTERN_ITEMSET:
  340. return None, f"unsupported source_kind={source_kind or '<empty>'}"
  341. element_names = _source_element_names(source, demand_item)
  342. category_ids, invalid_category_ids = _normalize_int_list(
  343. _first_present(source.get("category_ids"), source.get("category_id"))
  344. )
  345. if invalid_category_ids:
  346. return None, f"invalid category_ids: {invalid_category_ids}"
  347. category_names = _normalize_string_list(
  348. _first_present(source.get("category_names"), source.get("category_name"), source.get("categories"))
  349. )
  350. category_paths = _normalize_string_list(
  351. _first_present(source.get("category_paths"), source.get("category_path"))
  352. )
  353. element_types = _normalize_string_list(
  354. _first_present(source.get("element_types"), source.get("element_type"), source.get("dimension"))
  355. )
  356. groups: list[dict[str, Any]] = []
  357. if source_kind in {SOURCE_KIND_HIGH_WEIGHT_ELEMENT, SOURCE_KIND_ELEMENT_CO_OCCURRENCE} and element_names:
  358. groups.extend({"element_names": [name]} for name in element_names)
  359. elif source_kind in {SOURCE_KIND_HIGH_WEIGHT_CATEGORY, SOURCE_KIND_CATEGORY_CO_OCCURRENCE}:
  360. if category_ids:
  361. groups.extend({"category_ids": [category_id]} for category_id in category_ids)
  362. elif category_paths:
  363. groups.extend({"category_paths": [path]} for path in category_paths)
  364. elif category_names:
  365. groups.extend({"category_names": [name]} for name in category_names)
  366. elif element_names:
  367. groups.extend({"category_names": [name]} for name in element_names)
  368. if not groups:
  369. groups.append(
  370. {
  371. "element_names": element_names,
  372. "category_ids": category_ids,
  373. "category_names": category_names,
  374. "category_paths": category_paths,
  375. }
  376. )
  377. if not any(_source_group_has_criteria(group) for group in groups):
  378. return None, f"{source_kind} source has no resolvable names or categories"
  379. rows_by_group: list[list[dict[str, Any]]] = []
  380. for group in groups:
  381. rows = query_source_elements(
  382. execution_id=execution_id,
  383. element_names=group.get("element_names"),
  384. category_ids=group.get("category_ids"),
  385. category_names=group.get("category_names"),
  386. category_paths=group.get("category_paths"),
  387. element_types=element_types,
  388. merge_leve2=scope_merge_leve2,
  389. platform=scope_platform,
  390. limit=_env_int("DEMAND_EVIDENCE_SOURCE_ELEMENT_LIMIT", 20000),
  391. )
  392. if not rows:
  393. return None, f"{source_kind} source cannot resolve DB rows for {group}"
  394. rows_by_group.append(rows)
  395. matched_sets = [
  396. {str(row["post_id"]) for row in rows if row.get("post_id") is not None}
  397. for rows in rows_by_group
  398. ]
  399. matched_post_set = set.intersection(*matched_sets) if matched_sets else set()
  400. if not matched_post_set:
  401. return None, f"{source_kind} source has no common scoped support posts"
  402. matched_post_ids = sorted(matched_post_set)
  403. all_rows = [
  404. dict(row)
  405. for rows in rows_by_group
  406. for row in rows
  407. if str(row.get("post_id") or "") in matched_post_set
  408. ]
  409. category_bindings = _build_source_category_bindings(all_rows, source_kind=source_kind)
  410. element_bindings = _build_source_element_bindings(all_rows, source_kind=source_kind)
  411. if not category_bindings or not element_bindings:
  412. return None, f"{source_kind} source produced no category/element bindings"
  413. return {
  414. "source_kind": source_kind,
  415. "source_tool": _clean_str(source.get("source_tool")),
  416. "source_terms": _source_terms_from_bindings(category_bindings, element_bindings),
  417. "itemset_ids": [],
  418. "mining_config_ids": [],
  419. "support": None,
  420. "absolute_support": len(matched_post_ids),
  421. "matched_post_ids": matched_post_ids,
  422. "source_post_id": _choose_source_post_id(source, demand_item, matched_post_ids),
  423. "raw_itemset_items": [],
  424. "category_bindings": category_bindings,
  425. "element_bindings": element_bindings,
  426. "seed_terms": _source_terms_from_bindings(category_bindings, element_bindings),
  427. }, None
  428. def _resolve_source_kind(
  429. evidence_refs: Mapping[str, Any],
  430. demand_item: Any,
  431. itemset_ids: list[int],
  432. ) -> str:
  433. explicit = _clean_str(
  434. _first_present(
  435. evidence_refs.get("source_kind"),
  436. _read_field(demand_item, "source_kind"),
  437. )
  438. )
  439. if explicit:
  440. aliases = {
  441. "element": SOURCE_KIND_HIGH_WEIGHT_ELEMENT,
  442. "元素": SOURCE_KIND_HIGH_WEIGHT_ELEMENT,
  443. "high_weight": SOURCE_KIND_HIGH_WEIGHT_ELEMENT,
  444. "category": SOURCE_KIND_HIGH_WEIGHT_CATEGORY,
  445. "分类": SOURCE_KIND_HIGH_WEIGHT_CATEGORY,
  446. "co_occurrence": SOURCE_KIND_ELEMENT_CO_OCCURRENCE,
  447. "co-occurrence": SOURCE_KIND_ELEMENT_CO_OCCURRENCE,
  448. "co occurrence": SOURCE_KIND_ELEMENT_CO_OCCURRENCE,
  449. "element_co-occurrence": SOURCE_KIND_ELEMENT_CO_OCCURRENCE,
  450. "element co occurrence": SOURCE_KIND_ELEMENT_CO_OCCURRENCE,
  451. "category_co-occurrence": SOURCE_KIND_CATEGORY_CO_OCCURRENCE,
  452. "category co occurrence": SOURCE_KIND_CATEGORY_CO_OCCURRENCE,
  453. "关系": SOURCE_KIND_ELEMENT_CO_OCCURRENCE,
  454. "pattern": SOURCE_KIND_PATTERN_ITEMSET,
  455. }
  456. return aliases.get(explicit, explicit)
  457. source_tool = _clean_str(evidence_refs.get("source_tool"))
  458. if itemset_ids or source_tool == "get_itemset_detail":
  459. return SOURCE_KIND_PATTERN_ITEMSET
  460. if source_tool == "get_element_co_occurrences":
  461. return SOURCE_KIND_ELEMENT_CO_OCCURRENCE
  462. if source_tool == "get_category_co_occurrences":
  463. return SOURCE_KIND_CATEGORY_CO_OCCURRENCE
  464. if evidence_refs.get("category_id") or evidence_refs.get("category_ids"):
  465. return SOURCE_KIND_HIGH_WEIGHT_CATEGORY
  466. if evidence_refs.get("category_path") or evidence_refs.get("category_paths"):
  467. return SOURCE_KIND_HIGH_WEIGHT_CATEGORY
  468. if evidence_refs.get("category_name") or evidence_refs.get("category_names"):
  469. return SOURCE_KIND_HIGH_WEIGHT_CATEGORY
  470. if evidence_refs.get("element_names") or evidence_refs.get("element_name") or evidence_refs.get("name"):
  471. return SOURCE_KIND_HIGH_WEIGHT_ELEMENT
  472. return ""
  473. def _extract_itemset_ids_from_text(*values: Any) -> list[int]:
  474. ids: list[int] = []
  475. for value in values:
  476. if value is None:
  477. continue
  478. text = str(value)
  479. for match in re.finditer(r"itemset\\s*[_#:::-]?\\s*(\\d{3,})", text, flags=re.IGNORECASE):
  480. ids.append(int(match.group(1)))
  481. return _unique_ints(ids)
  482. def _extract_evidence_refs(demand_item: Any) -> dict[str, Any]:
  483. value = _read_field(demand_item, "evidence_refs", {})
  484. if isinstance(value, str):
  485. raw = value.strip()
  486. if not raw:
  487. return {}
  488. try:
  489. parsed = json.loads(raw)
  490. except json.JSONDecodeError:
  491. return {}
  492. return dict(parsed) if isinstance(parsed, Mapping) else {}
  493. return dict(value) if isinstance(value, Mapping) else {}
  494. def _resolve_demand_scope(
  495. *,
  496. execution_id: int,
  497. evidence_refs: Mapping[str, Any],
  498. demand_item: Any,
  499. demand_scope: Mapping[str, Any] | None,
  500. merge_leve2: str | None,
  501. platform: str | None,
  502. ) -> dict[str, Any]:
  503. raw_scope = {}
  504. for value in (
  505. demand_scope,
  506. evidence_refs.get("demand_scope"),
  507. _read_field(demand_item, "demand_scope"),
  508. ):
  509. if isinstance(value, Mapping):
  510. raw_scope = dict(value)
  511. break
  512. scope_source = _clean_str(raw_scope.get("scope_source"))
  513. if not scope_source:
  514. scope_source = "odps_gap" if raw_scope.get("gap_dt") or raw_scope.get("lack_count") is not None else "manual_cli"
  515. resolved = {
  516. "scope_source": scope_source,
  517. "merge_leve2": _clean_str(
  518. _first_present(
  519. merge_leve2,
  520. raw_scope.get("merge_leve2"),
  521. raw_scope.get("merge_level2"),
  522. evidence_refs.get("merge_leve2"),
  523. evidence_refs.get("merge_level2"),
  524. )
  525. ),
  526. "platform": normalize_platform(
  527. _clean_str(
  528. _first_present(
  529. platform,
  530. raw_scope.get("platform"),
  531. raw_scope.get("platform_type"),
  532. evidence_refs.get("platform"),
  533. evidence_refs.get("platform_type"),
  534. DEFAULT_DEMAND_PLATFORM,
  535. )
  536. )
  537. ),
  538. "pattern_execution_id": int(execution_id),
  539. }
  540. for field in ("gap_dt", "requested_count", "lack_count"):
  541. value = raw_scope.get(field)
  542. if value is not None and value != "":
  543. resolved[field] = value
  544. return resolved
  545. def _env_int(name: str, default: int) -> int:
  546. try:
  547. return int(os.getenv(name, str(default)))
  548. except ValueError:
  549. return default
  550. def _read_field(source: Any, field: str, default: Any = None) -> Any:
  551. if source is None:
  552. return default
  553. if isinstance(source, Mapping):
  554. return source.get(field, default)
  555. if hasattr(source, field):
  556. return getattr(source, field)
  557. if hasattr(source, "model_dump"):
  558. dumped = source.model_dump()
  559. if isinstance(dumped, Mapping):
  560. return dumped.get(field, default)
  561. if hasattr(source, "dict"):
  562. dumped = source.dict()
  563. if isinstance(dumped, Mapping):
  564. return dumped.get(field, default)
  565. return default
  566. def _first_present(*values: Any) -> Any:
  567. for value in values:
  568. if value is None:
  569. continue
  570. if isinstance(value, str) and not value.strip():
  571. continue
  572. if isinstance(value, (list, tuple, set, dict)) and len(value) == 0:
  573. continue
  574. return value
  575. return None
  576. def _clean_str(value: Any) -> str:
  577. return str(value).strip() if value is not None else ""
  578. def _normalize_list(value: Any) -> list[Any]:
  579. if value is None:
  580. return []
  581. if isinstance(value, list):
  582. return value
  583. if isinstance(value, (tuple, set)):
  584. return list(value)
  585. if isinstance(value, str):
  586. raw = value.strip()
  587. if not raw:
  588. return []
  589. try:
  590. parsed = json.loads(raw)
  591. except json.JSONDecodeError:
  592. return [part.strip() for part in raw.split(",") if part.strip()]
  593. if isinstance(parsed, list):
  594. return parsed
  595. if parsed is None:
  596. return []
  597. return [parsed]
  598. return [value]
  599. def _normalize_int_list(value: Any) -> tuple[list[int], list[Any]]:
  600. result: list[int] = []
  601. invalid: list[Any] = []
  602. seen: set[int] = set()
  603. for raw in _normalize_list(value):
  604. try:
  605. parsed = int(raw)
  606. except (TypeError, ValueError):
  607. invalid.append(raw)
  608. continue
  609. if parsed not in seen:
  610. seen.add(parsed)
  611. result.append(parsed)
  612. return result, invalid
  613. def _normalize_string_list(value: Any) -> list[str]:
  614. result: list[str] = []
  615. seen: set[str] = set()
  616. for raw in _normalize_list(value):
  617. text = _clean_str(raw)
  618. if text and text not in seen:
  619. seen.add(text)
  620. result.append(text)
  621. return result
  622. def _source_group_has_criteria(group: Mapping[str, Any]) -> bool:
  623. return any(_normalize_list(value) for value in group.values())
  624. def _source_element_names(source: Mapping[str, Any], demand_item: Any) -> list[str]:
  625. return _normalize_string_list(
  626. _first_present(
  627. source.get("element_names"),
  628. source.get("element_name"),
  629. source.get("names"),
  630. source.get("name"),
  631. source.get("seed_terms"),
  632. _read_field(demand_item, "element_names"),
  633. )
  634. )
  635. def _unique_ints(values: Iterable[Any]) -> list[int]:
  636. result: list[int] = []
  637. seen: set[int] = set()
  638. for value in values:
  639. if value is None:
  640. continue
  641. try:
  642. parsed = int(value)
  643. except (TypeError, ValueError):
  644. continue
  645. if parsed not in seen:
  646. seen.add(parsed)
  647. result.append(parsed)
  648. return result
  649. def _unique_strings(values: Iterable[Any]) -> list[str]:
  650. result: list[str] = []
  651. seen: set[str] = set()
  652. for value in values:
  653. text = _clean_str(value)
  654. if text and text not in seen:
  655. seen.add(text)
  656. result.append(text)
  657. return result
  658. def _validate_itemset_facts(itemsets: list[dict[str, Any]]) -> str | None:
  659. for itemset in itemsets:
  660. itemset_id = itemset.get("id")
  661. execution_id = itemset.get("execution_id")
  662. if itemset.get("mining_config_id") is None:
  663. return f"itemset_id {itemset_id} missing mining_config_id"
  664. if int(itemset.get("mining_config_execution_id") or -1) != int(execution_id):
  665. return (
  666. f"itemset_id {itemset_id} mining_config_id {itemset.get('mining_config_id')} "
  667. f"does not belong to execution_id {execution_id}"
  668. )
  669. if _clean_str(itemset.get("scope")) != PATTERN_ITEMSET_SCOPE:
  670. return (
  671. f"itemset_id {itemset_id} scope={itemset.get('scope')} is not "
  672. f"{PATTERN_ITEMSET_SCOPE}"
  673. )
  674. if _clean_str(itemset.get("mining_config_scope")) != PATTERN_ITEMSET_SCOPE:
  675. return (
  676. f"itemset_id {itemset_id} mining_config_id {itemset.get('mining_config_id')} "
  677. f"scope={itemset.get('mining_config_scope')} is not {PATTERN_ITEMSET_SCOPE}"
  678. )
  679. if itemset.get("support") is None:
  680. return f"itemset_id {itemset_id} missing support"
  681. if itemset.get("absolute_support") is None:
  682. return f"itemset_id {itemset_id} missing absolute_support"
  683. matched_post_ids = itemset.get("matched_post_ids") or []
  684. if not matched_post_ids:
  685. return f"itemset_id {itemset_id} missing matched_post_ids"
  686. scoped_post_count = itemset.get("scoped_post_count")
  687. if scoped_post_count is not None:
  688. scoped_count = int(scoped_post_count)
  689. if len(matched_post_ids) != scoped_count:
  690. return (
  691. f"itemset_id {itemset_id} matched_post_ids count {len(matched_post_ids)} "
  692. f"does not equal scoped_post_count {scoped_count}"
  693. )
  694. if scoped_count > int(itemset["absolute_support"]):
  695. return (
  696. f"itemset_id {itemset_id} scoped_post_count {scoped_count} "
  697. f"is greater than absolute_support {itemset['absolute_support']}"
  698. )
  699. elif len(matched_post_ids) != int(itemset["absolute_support"]):
  700. return (
  701. f"itemset_id {itemset_id} matched_post_ids count {len(matched_post_ids)} "
  702. f"does not equal absolute_support {itemset['absolute_support']}"
  703. )
  704. return None
  705. def _validate_itemset_items(
  706. execution_id: int,
  707. itemsets: list[dict[str, Any]],
  708. items_by_itemset: dict[int, list[dict[str, Any]]],
  709. ) -> str | None:
  710. for itemset in itemsets:
  711. itemset_id = int(itemset["id"])
  712. items = items_by_itemset.get(itemset_id, [])
  713. if not items:
  714. return f"itemset_id {itemset_id} has no itemset_items"
  715. expected_count = itemset.get("item_count")
  716. if expected_count is not None and int(expected_count) != len(items):
  717. return (
  718. f"itemset_id {itemset_id} item_count mismatch: "
  719. f"expected {expected_count}, got {len(items)}"
  720. )
  721. for item in items:
  722. category_id = item.get("category_id")
  723. if category_id is None:
  724. return f"itemset_id {itemset_id} has item without category_id"
  725. if not item.get("category_found"):
  726. return (
  727. f"category_id {category_id} for itemset_id {itemset_id} "
  728. f"not found under execution_id {execution_id}"
  729. )
  730. if int(item.get("category_execution_id")) != int(execution_id):
  731. return (
  732. f"category_id {category_id} belongs to execution_id "
  733. f"{item.get('category_execution_id')}, not {execution_id}"
  734. )
  735. return None
  736. def _merge_post_ids(post_id_groups: Iterable[Any]) -> list[str]:
  737. merged: list[str] = []
  738. seen: set[str] = set()
  739. for group in post_id_groups:
  740. for raw_post_id in _normalize_list(group):
  741. post_id = _clean_str(raw_post_id)
  742. if post_id and post_id not in seen:
  743. seen.add(post_id)
  744. merged.append(post_id)
  745. return merged
  746. def _choose_source_post_id(
  747. evidence_refs: Mapping[str, Any],
  748. demand_item: Any,
  749. matched_post_ids: list[str],
  750. ) -> str:
  751. candidate = _clean_str(
  752. _first_present(
  753. evidence_refs.get("source_post_id"),
  754. evidence_refs.get("post_id"),
  755. _read_field(demand_item, "source_post_id"),
  756. _read_field(demand_item, "post_id"),
  757. )
  758. )
  759. if candidate:
  760. return candidate
  761. candidate_posts = _normalize_list(
  762. _first_present(
  763. evidence_refs.get("video_ids"),
  764. evidence_refs.get("matched_post_ids"),
  765. _read_field(demand_item, "video_ids"),
  766. )
  767. )
  768. matched_set = set(matched_post_ids)
  769. for raw_post_id in candidate_posts:
  770. post_id = _clean_str(raw_post_id)
  771. if post_id in matched_set:
  772. return post_id
  773. return matched_post_ids[0] if matched_post_ids else ""
  774. def _resolve_source_post_with_bindings(
  775. execution_id: int,
  776. itemset_items: list[dict[str, Any]],
  777. matched_post_ids: list[str],
  778. preferred_post_id: str,
  779. ) -> tuple[str, list[dict[str, Any]], str | None]:
  780. broad_bindings = query_element_bindings_for_items(
  781. execution_id=int(execution_id),
  782. itemset_items=itemset_items,
  783. post_ids=matched_post_ids,
  784. limit_per_item=max(len(matched_post_ids), 200),
  785. )
  786. by_item_id = {
  787. int(binding["itemset_item_id"]): set(_normalize_list(binding.get("matched_post_ids")))
  788. for binding in broad_bindings
  789. if binding.get("itemset_item_id") is not None
  790. }
  791. candidate_posts: set[str] | None = set(matched_post_ids)
  792. for item in itemset_items:
  793. item_id = int(item["itemset_item_id"])
  794. item_posts = {str(post_id) for post_id in by_item_id.get(item_id, set())}
  795. if not item_posts:
  796. return "", broad_bindings, f"itemset_item_id {item_id} has no exact PG topic element binding"
  797. candidate_posts = candidate_posts & item_posts if candidate_posts is not None else item_posts
  798. ordered_candidates = [post_id for post_id in matched_post_ids if post_id in (candidate_posts or set())]
  799. if not ordered_candidates:
  800. return "", broad_bindings, "no source_post_id can close all PG topic element bindings"
  801. source_post_id = preferred_post_id if preferred_post_id in ordered_candidates else ordered_candidates[0]
  802. exact_bindings = query_element_bindings_for_items(
  803. execution_id=int(execution_id),
  804. itemset_items=itemset_items,
  805. post_ids=[source_post_id],
  806. )
  807. binding_reason = _validate_element_bindings(itemset_items, exact_bindings)
  808. if binding_reason:
  809. return "", exact_bindings, binding_reason
  810. return source_post_id, exact_bindings, None
  811. def _validate_element_bindings(
  812. itemset_items: list[dict[str, Any]],
  813. element_bindings: list[dict[str, Any]],
  814. ) -> str | None:
  815. by_item_id = {
  816. int(binding["itemset_item_id"]): binding
  817. for binding in element_bindings
  818. if binding.get("itemset_item_id") is not None
  819. }
  820. for item in itemset_items:
  821. item_id = int(item["itemset_item_id"])
  822. binding = by_item_id.get(item_id)
  823. if not binding:
  824. return f"itemset_item_id {item_id} has no element binding"
  825. if int(binding.get("matched_element_count") or 0) <= 0:
  826. return f"itemset_item_id {item_id} has no exact PG topic element binding"
  827. if int(binding.get("matched_post_count") or 0) <= 0:
  828. return f"itemset_item_id {item_id} has no matched post binding"
  829. return None
  830. def _build_seed_terms_from_itemset_items(itemset_items: list[dict[str, Any]]) -> list[str]:
  831. candidates: list[str] = []
  832. for item in itemset_items:
  833. for value in (
  834. item.get("element_name"),
  835. item.get("category_name"),
  836. _last_path_part(item.get("category_path")),
  837. _last_path_part(item.get("category_full_path")),
  838. ):
  839. text = _clean_str(value)
  840. if text and text not in candidates:
  841. candidates.append(text)
  842. filtered = [
  843. term for term in candidates
  844. if _normalize_term(term) not in {"其他", "其它", "未知", "内容", "视频"}
  845. ]
  846. source = filtered or candidates
  847. return _maybe_limit_seed_terms(source)
  848. def _validate_seed_terms(
  849. seed_terms: list[str],
  850. itemset_items: list[dict[str, Any]],
  851. element_bindings: list[dict[str, Any]],
  852. ) -> str | None:
  853. if not seed_terms:
  854. return "seed_terms cannot be resolved from itemset evidence"
  855. covered_terms = _build_covered_terms(itemset_items, element_bindings)
  856. uncovered = [term for term in seed_terms if _normalize_term(term) not in covered_terms]
  857. if uncovered:
  858. return f"seed_terms not covered by DB evidence: {uncovered}"
  859. return None
  860. def _build_covered_terms(
  861. itemset_items: list[dict[str, Any]],
  862. element_bindings: list[dict[str, Any]],
  863. ) -> set[str]:
  864. terms: set[str] = set()
  865. for item in itemset_items:
  866. for field in ("element_name", "category_name", "category_path", "category_full_path"):
  867. _add_term_variants(terms, item.get(field))
  868. for binding in element_bindings:
  869. _add_term_variants(terms, binding.get("element_name"))
  870. for sample in binding.get("sample_elements") or []:
  871. _add_term_variants(terms, sample.get("name"))
  872. _add_term_variants(terms, sample.get("category_path"))
  873. return terms
  874. def _add_term_variants(terms: set[str], value: Any) -> None:
  875. text = _clean_str(value)
  876. if not text:
  877. return
  878. terms.add(_normalize_term(text))
  879. for part in _split_pathish(text):
  880. terms.add(_normalize_term(part))
  881. def _normalize_term(value: Any) -> str:
  882. return re.sub(r"\s+", "", _clean_str(value))
  883. def _split_pathish(value: Any) -> list[str]:
  884. text = _clean_str(value)
  885. if not text:
  886. return []
  887. return [part.strip() for part in re.split(r"[>/|,,]+", text) if part.strip()]
  888. def _last_path_part(value: Any) -> str:
  889. parts = _split_pathish(value)
  890. return parts[-1] if parts else ""
  891. def _build_category_bindings(itemset_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
  892. bindings: list[dict[str, Any]] = []
  893. seen: set[tuple[int, int | None]] = set()
  894. for item in itemset_items:
  895. key = (int(item["category_id"]), item.get("itemset_id"))
  896. if key in seen:
  897. continue
  898. seen.add(key)
  899. bindings.append(
  900. {
  901. "itemset_id": item.get("itemset_id"),
  902. "itemset_item_id": item.get("itemset_item_id"),
  903. "category_id": item.get("category_id"),
  904. "category_name": item.get("category_name"),
  905. "category_path": item.get("category_path") or item.get("category_full_path"),
  906. "category_full_path": item.get("category_full_path"),
  907. "category_source_type": item.get("category_source_type"),
  908. "category_level": item.get("category_level"),
  909. "point_type": item.get("point_type"),
  910. "dimension": item.get("dimension"),
  911. "element_name": item.get("element_name"),
  912. }
  913. )
  914. return bindings
  915. def _build_source_category_bindings(rows: list[dict[str, Any]], *, source_kind: str) -> list[dict[str, Any]]:
  916. bindings: list[dict[str, Any]] = []
  917. seen: set[tuple[Any, ...]] = set()
  918. for row in rows:
  919. category_id = row.get("category_id")
  920. category_path = row.get("category_path") or row.get("category_full_path")
  921. if category_id is None and not category_path:
  922. continue
  923. key = (category_id, category_path, row.get("element_type"), source_kind)
  924. if key in seen:
  925. continue
  926. seen.add(key)
  927. bindings.append(
  928. {
  929. "source_kind": source_kind,
  930. "category_id": int(category_id) if category_id is not None else None,
  931. "category_name": row.get("category_name") or _last_path_part(category_path),
  932. "category_path": category_path,
  933. "category_full_path": row.get("category_full_path") or category_path,
  934. "category_source_type": row.get("category_source_type"),
  935. "category_level": row.get("category_level"),
  936. "category_nature": row.get("category_nature"),
  937. "point_type": row.get("point_type"),
  938. "dimension": row.get("element_type"),
  939. "element_name": row.get("name"),
  940. }
  941. )
  942. return bindings
  943. def _build_element_bindings(element_bindings: list[dict[str, Any]]) -> list[dict[str, Any]]:
  944. return [
  945. {
  946. "itemset_id": binding.get("itemset_id"),
  947. "itemset_item_id": binding.get("itemset_item_id"),
  948. "category_id": binding.get("category_id"),
  949. "point_type": binding.get("point_type"),
  950. "dimension": binding.get("dimension"),
  951. "element_name": binding.get("element_name"),
  952. "matched_element_count": binding.get("matched_element_count"),
  953. "matched_post_count": binding.get("matched_post_count"),
  954. "matched_post_ids": binding.get("matched_post_ids") or [],
  955. "sample_elements": binding.get("sample_elements") or [],
  956. }
  957. for binding in element_bindings
  958. ]
  959. def _build_source_element_bindings(rows: list[dict[str, Any]], *, source_kind: str) -> list[dict[str, Any]]:
  960. grouped: dict[tuple[Any, ...], dict[str, Any]] = {}
  961. for row in rows:
  962. key = (
  963. source_kind,
  964. row.get("category_id"),
  965. row.get("point_type"),
  966. row.get("element_type"),
  967. row.get("name"),
  968. )
  969. group = grouped.setdefault(
  970. key,
  971. {
  972. "source_kind": source_kind,
  973. "category_id": int(row["category_id"]) if row.get("category_id") is not None else None,
  974. "point_type": row.get("point_type"),
  975. "dimension": row.get("element_type"),
  976. "element_name": row.get("name"),
  977. "matched_element_count": 0,
  978. "matched_post_ids": [],
  979. "sample_elements": [],
  980. },
  981. )
  982. group["matched_element_count"] += 1
  983. post_id = _clean_str(row.get("post_id"))
  984. if post_id and post_id not in group["matched_post_ids"]:
  985. group["matched_post_ids"].append(post_id)
  986. if len(group["sample_elements"]) < 20:
  987. group["sample_elements"].append(_source_element_sample(row))
  988. result: list[dict[str, Any]] = []
  989. for group in grouped.values():
  990. group["matched_post_count"] = len(group["matched_post_ids"])
  991. result.append(group)
  992. result.sort(
  993. key=lambda item: (
  994. -int(item.get("matched_post_count") or 0),
  995. str(item.get("element_name") or ""),
  996. str(item.get("category_id") or ""),
  997. )
  998. )
  999. return result
  1000. def _source_element_sample(row: Mapping[str, Any]) -> dict[str, Any]:
  1001. return {
  1002. "id": row.get("id"),
  1003. "name": row.get("name"),
  1004. "post_id": _clean_str(row.get("post_id")),
  1005. "point_text": row.get("point_text"),
  1006. "point_type": row.get("point_type"),
  1007. "category_id": row.get("category_id"),
  1008. "element_type": row.get("element_type"),
  1009. "source_table": row.get("source_table"),
  1010. "category_path": row.get("category_path") or row.get("category_full_path"),
  1011. "topic_point_id": row.get("topic_point_id"),
  1012. "source_element_id": row.get("source_element_id"),
  1013. }
  1014. def _build_itemset_items(itemset_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
  1015. return [
  1016. {
  1017. "itemset_id": item.get("itemset_id"),
  1018. "category_id": item.get("category_id"),
  1019. "category_path": item.get("category_path") or item.get("category_full_path"),
  1020. "point_type": item.get("point_type"),
  1021. "dimension": item.get("dimension"),
  1022. "element_name": item.get("element_name"),
  1023. }
  1024. for item in itemset_items
  1025. ]
  1026. def _dedupe_itemset_items(items: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
  1027. result: list[dict[str, Any]] = []
  1028. seen: set[tuple[Any, ...]] = set()
  1029. for item in items:
  1030. key = (
  1031. item.get("itemset_item_id"),
  1032. item.get("itemset_id"),
  1033. item.get("category_id"),
  1034. item.get("point_type"),
  1035. item.get("dimension"),
  1036. item.get("element_name"),
  1037. )
  1038. if key in seen:
  1039. continue
  1040. seen.add(key)
  1041. result.append(dict(item))
  1042. return result
  1043. def _dedupe_bindings(bindings: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
  1044. result: list[dict[str, Any]] = []
  1045. seen: set[tuple[Any, ...]] = set()
  1046. for binding in bindings:
  1047. key = (
  1048. binding.get("source_kind"),
  1049. binding.get("itemset_id"),
  1050. binding.get("itemset_item_id"),
  1051. binding.get("category_id"),
  1052. binding.get("category_path"),
  1053. binding.get("point_type"),
  1054. binding.get("dimension"),
  1055. binding.get("element_name"),
  1056. )
  1057. if key in seen:
  1058. continue
  1059. seen.add(key)
  1060. result.append(dict(binding))
  1061. return result
  1062. def _source_terms_from_bindings(
  1063. category_bindings: list[dict[str, Any]],
  1064. element_bindings: list[dict[str, Any]],
  1065. ) -> list[str]:
  1066. terms: list[str] = []
  1067. for binding in element_bindings:
  1068. for value in (binding.get("element_name"),):
  1069. text = _clean_str(value)
  1070. if text and text not in terms:
  1071. terms.append(text)
  1072. for binding in category_bindings:
  1073. for value in (binding.get("category_name"), _last_path_part(binding.get("category_path"))):
  1074. text = _clean_str(value)
  1075. if text and text not in terms:
  1076. terms.append(text)
  1077. return terms
  1078. def _build_seed_terms_from_sources(
  1079. demand_item: Any,
  1080. resolved_sources: list[dict[str, Any]],
  1081. itemset_items: list[dict[str, Any]],
  1082. element_bindings: list[dict[str, Any]],
  1083. ) -> list[str]:
  1084. del demand_item
  1085. candidates: list[str] = []
  1086. for source in resolved_sources:
  1087. for value in source.get("seed_terms", []) or []:
  1088. text = _clean_str(value)
  1089. if text and text not in candidates:
  1090. candidates.append(text)
  1091. for value in source.get("source_terms", []) or []:
  1092. text = _clean_str(value)
  1093. if text and text not in candidates:
  1094. candidates.append(text)
  1095. if not candidates and itemset_items:
  1096. candidates.extend(_build_seed_terms_from_itemset_items(itemset_items))
  1097. if not candidates:
  1098. candidates.extend(_source_terms_from_bindings([], element_bindings))
  1099. filtered = [
  1100. term for term in candidates
  1101. if _normalize_term(term) not in {"其他", "其它", "未知", "内容", "视频"}
  1102. ]
  1103. source = filtered or candidates
  1104. return _maybe_limit_seed_terms(source)
  1105. def _maybe_limit_seed_terms(seed_terms: list[str]) -> list[str]:
  1106. raw_limit = os.getenv("DEMAND_SEED_TERMS_MAX")
  1107. if raw_limit is None or not str(raw_limit).strip():
  1108. return seed_terms
  1109. try:
  1110. max_terms = int(str(raw_limit).strip())
  1111. except ValueError:
  1112. return seed_terms
  1113. if max_terms <= 0:
  1114. return seed_terms
  1115. return seed_terms[:max_terms]
  1116. def _resolve_support(
  1117. execution: Mapping[str, Any],
  1118. resolved_sources: list[dict[str, Any]],
  1119. matched_post_ids: list[str],
  1120. ) -> float:
  1121. itemset_supports = [
  1122. float(source["support"])
  1123. for source in resolved_sources
  1124. if source.get("support") is not None
  1125. ]
  1126. if itemset_supports:
  1127. return min(itemset_supports)
  1128. post_count = int(execution.get("post_count") or 0)
  1129. return float(len(matched_post_ids)) / float(post_count) if post_count > 0 else 0.0
  1130. def _resolve_absolute_support(
  1131. execution: Mapping[str, Any],
  1132. resolved_sources: list[dict[str, Any]],
  1133. matched_post_ids: list[str],
  1134. ) -> int:
  1135. del execution
  1136. itemset_supports = [
  1137. int(source["absolute_support"])
  1138. for source in resolved_sources
  1139. if source.get("absolute_support") is not None and source.get("itemset_ids")
  1140. ]
  1141. return max([len(matched_post_ids), *itemset_supports])
  1142. def _build_evidence_sources_payload(resolved_sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
  1143. payload: list[dict[str, Any]] = []
  1144. for source in resolved_sources:
  1145. payload.append(
  1146. {
  1147. "source_kind": source.get("source_kind"),
  1148. "source_tool": source.get("source_tool"),
  1149. "itemset_ids": source.get("itemset_ids") or [],
  1150. "mining_config_ids": source.get("mining_config_ids") or [],
  1151. "source_terms": source.get("source_terms") or source.get("seed_terms") or [],
  1152. "source_post_id": source.get("source_post_id"),
  1153. "matched_post_count": len(source.get("matched_post_ids") or []),
  1154. "matched_post_ids": source.get("matched_post_ids") or [],
  1155. "support": source.get("support"),
  1156. "absolute_support": source.get("absolute_support"),
  1157. }
  1158. )
  1159. return payload