pg_pattern_repository.py 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259
  1. """Read-only PostgreSQL Pattern V2 repository for DemandAgent.
  2. This module is the DemandAgent boundary to PG `open_aigc.public` Pattern V2
  3. facts. It intentionally exposes the old Demand-side query shapes while reading
  4. only PG main-truth tables:
  5. - pattern_mining_execution
  6. - pattern_mining_category
  7. - pattern_mining_element
  8. - pattern_mining_config
  9. - pattern_itemset
  10. - pattern_itemset_item
  11. - pattern_itemset_post
  12. """
  13. from __future__ import annotations
  14. import json
  15. import os
  16. import re
  17. from contextlib import contextmanager
  18. from datetime import date, datetime
  19. from typing import Any, Iterable, Mapping, Sequence
  20. try:
  21. import psycopg2
  22. from psycopg2.extras import RealDictCursor
  23. except Exception: # pragma: no cover - exercised in environments without deps
  24. psycopg2 = None
  25. RealDictCursor = None
  26. PG_PATTERN_SOURCE_SYSTEM = "pg_pattern_v2"
  27. TOPIC_SCOPE = "topic"
  28. TOPIC_ELEMENT_SOURCE_TABLE = "post_decode_topic_point_element"
  29. ELEMENT_TYPE_DIMENSIONS = {"实质", "形式", "意图"}
  30. _READONLY_SQL_RE = re.compile(r"^\s*(select|with|show|explain)\b", re.IGNORECASE | re.DOTALL)
  31. _BLOCKED_SQL_RE = re.compile(
  32. r"\b(insert|update|delete|create|alter|drop|truncate|copy|grant|revoke|merge|call)\b",
  33. re.IGNORECASE,
  34. )
  35. def _load_dotenv_once() -> None:
  36. try:
  37. from dotenv import load_dotenv
  38. except Exception:
  39. return
  40. load_dotenv()
  41. def _resolve_dsn() -> str:
  42. _load_dotenv_once()
  43. dsn = os.getenv("DEMAND_PATTERN_PG_DSN") or os.getenv("PGVECTOR_DSN")
  44. if not dsn:
  45. raise RuntimeError("PG Pattern V2 DSN missing: set DEMAND_PATTERN_PG_DSN or PGVECTOR_DSN")
  46. return dsn
  47. def _assert_readonly_sql(sql: str) -> None:
  48. if not _READONLY_SQL_RE.search(sql):
  49. raise RuntimeError("PG Pattern repository only allows SELECT/SHOW/EXPLAIN queries")
  50. if _BLOCKED_SQL_RE.search(sql):
  51. raise RuntimeError("PG Pattern repository blocked a non-read-only SQL keyword")
  52. def _is_element_type_dimension(dimension: Any) -> bool:
  53. """Return whether an item dimension maps to pattern_mining_element.element_type."""
  54. return dimension in ELEMENT_TYPE_DIMENSIONS
  55. @contextmanager
  56. def _connect_readonly():
  57. if psycopg2 is None:
  58. raise RuntimeError("psycopg2 is required for PG Pattern V2 reads")
  59. conn = psycopg2.connect(_resolve_dsn())
  60. try:
  61. conn.set_session(readonly=True, autocommit=True)
  62. yield conn
  63. finally:
  64. conn.close()
  65. def _fetch_all(sql: str, params: Sequence[Any] | Mapping[str, Any] | None = None) -> list[dict[str, Any]]:
  66. _assert_readonly_sql(sql)
  67. with _connect_readonly() as conn:
  68. with conn.cursor(cursor_factory=RealDictCursor) as cursor:
  69. cursor.execute(sql, params)
  70. return [_row_to_dict(row) for row in cursor.fetchall()]
  71. def _fetch_one(sql: str, params: Sequence[Any] | Mapping[str, Any] | None = None) -> dict[str, Any] | None:
  72. rows = _fetch_all(sql, params)
  73. return rows[0] if rows else None
  74. def _cursor_fetch_all(cursor: Any, sql: str, params: Sequence[Any] | Mapping[str, Any] | None = None) -> list[dict[str, Any]]:
  75. _assert_readonly_sql(sql)
  76. cursor.execute(sql, params)
  77. return [_row_to_dict(row) for row in cursor.fetchall()]
  78. def _cursor_fetch_one(cursor: Any, sql: str, params: Sequence[Any] | Mapping[str, Any] | None = None) -> dict[str, Any] | None:
  79. rows = _cursor_fetch_all(cursor, sql, params)
  80. return rows[0] if rows else None
  81. def _serialize_db_value(value: Any) -> Any:
  82. if isinstance(value, (datetime, date)):
  83. return value.isoformat()
  84. return value
  85. def _row_to_dict(row: Any) -> dict[str, Any]:
  86. if row is None:
  87. return {}
  88. mapping = getattr(row, "_mapping", row)
  89. return {key: _serialize_db_value(value) for key, value in dict(mapping).items()}
  90. def _jsonish_to_list(value: Any) -> list[Any]:
  91. if value is None:
  92. return []
  93. if isinstance(value, list):
  94. return value
  95. if isinstance(value, (tuple, set)):
  96. return list(value)
  97. if isinstance(value, bytes):
  98. value = value.decode("utf-8")
  99. if isinstance(value, str):
  100. raw = value.strip()
  101. if not raw:
  102. return []
  103. try:
  104. parsed = json.loads(raw)
  105. except json.JSONDecodeError:
  106. return [part.strip() for part in raw.split(",") if part.strip()]
  107. if isinstance(parsed, list):
  108. return parsed
  109. if parsed is None:
  110. return []
  111. return [parsed]
  112. return [value]
  113. def _to_post_id_list(value: Any) -> list[str]:
  114. post_ids: list[str] = []
  115. seen: set[str] = set()
  116. for item in _jsonish_to_list(value):
  117. post_id = str(item).strip() if item is not None else ""
  118. if post_id and post_id not in seen:
  119. seen.add(post_id)
  120. post_ids.append(post_id)
  121. return post_ids
  122. def _to_str_filter_list(value: Any) -> list[str]:
  123. result: list[str] = []
  124. seen: set[str] = set()
  125. for item in _jsonish_to_list(value):
  126. text = str(item).strip() if item is not None else ""
  127. if text and text not in seen:
  128. seen.add(text)
  129. result.append(text)
  130. return result
  131. def _expand_platform_filters(values: list[str]) -> list[str]:
  132. """Expand known platform aliases used by Hive and PG post rows."""
  133. aliases = {
  134. "piaoquan": ["piaoquan", "票圈"],
  135. "票圈": ["票圈", "piaoquan"],
  136. }
  137. result: list[str] = []
  138. seen: set[str] = set()
  139. for value in values:
  140. for candidate in aliases.get(value, [value]):
  141. if candidate and candidate not in seen:
  142. seen.add(candidate)
  143. result.append(candidate)
  144. return result
  145. def _to_int_list(values: Any) -> list[int]:
  146. result: list[int] = []
  147. seen: set[int] = set()
  148. for value in _jsonish_to_list(values):
  149. if value is None or value == "":
  150. continue
  151. try:
  152. int_value = int(value)
  153. except (TypeError, ValueError):
  154. continue
  155. if int_value not in seen:
  156. seen.add(int_value)
  157. result.append(int_value)
  158. return result
  159. def _scope_filter_payload(merge_leve2: Any = None, platform: Any = None) -> dict[str, list[str]]:
  160. return {
  161. "merge_leve2": _to_str_filter_list(merge_leve2),
  162. "platform": _expand_platform_filters(_to_str_filter_list(platform)),
  163. }
  164. def _scope_is_active(scope_filter: Mapping[str, list[str]]) -> bool:
  165. return bool(scope_filter.get("merge_leve2") or scope_filter.get("platform"))
  166. def _clean_names(names: Iterable[str]) -> list[str]:
  167. result: list[str] = []
  168. seen: set[str] = set()
  169. for raw in names:
  170. name = str(raw).strip() if raw is not None else ""
  171. if name and name not in seen:
  172. seen.add(name)
  173. result.append(name)
  174. return result
  175. def query_execution_for_evidence(execution_id: int) -> dict[str, Any] | None:
  176. """Read PG Pattern V2 execution facts for evidence validation."""
  177. row = _fetch_one(
  178. """
  179. SELECT
  180. id,
  181. snapshot_date,
  182. is_current,
  183. is_retired,
  184. status,
  185. post_filter,
  186. post_count,
  187. post_ids_digest,
  188. classify_execution_id,
  189. category_count,
  190. element_count,
  191. topic_itemset_count AS itemset_count,
  192. topic_itemset_count,
  193. topic_element_itemset_count,
  194. cross_itemset_count,
  195. script_sequence_count,
  196. paragraph_itemset_count,
  197. start_time,
  198. end_time,
  199. error_message
  200. FROM pattern_mining_execution
  201. WHERE id = %s
  202. LIMIT 1
  203. """,
  204. (int(execution_id),),
  205. )
  206. if not row:
  207. return None
  208. row["pattern_source_system"] = PG_PATTERN_SOURCE_SYSTEM
  209. return row
  210. def query_latest_success_execution_id() -> int | None:
  211. row = _fetch_one(
  212. """
  213. SELECT id
  214. FROM pattern_mining_execution
  215. WHERE status = 'success'
  216. AND COALESCE(topic_itemset_count, 0) > 0
  217. ORDER BY snapshot_date DESC NULLS LAST, id DESC
  218. LIMIT 1
  219. """
  220. )
  221. return int(row["id"]) if row else None
  222. def query_itemset_evidence(
  223. execution_id: int,
  224. itemset_ids: Iterable[Any],
  225. *,
  226. merge_leve2: Any = None,
  227. platform: Any = None,
  228. ) -> list[dict[str, Any]]:
  229. """Read PG topic-scope itemset facts and aggregate matched posts."""
  230. clean_ids = _to_int_list(itemset_ids or [])
  231. if not clean_ids:
  232. return []
  233. scope_filter = _scope_filter_payload(merge_leve2=merge_leve2, platform=platform)
  234. scoped = _scope_is_active(scope_filter)
  235. scope_clauses: list[str] = []
  236. scope_params: list[Any] = []
  237. if scope_filter["merge_leve2"]:
  238. scope_clauses.append("p_scope.merge_leve2 = ANY(%s)")
  239. scope_params.append(scope_filter["merge_leve2"])
  240. if scope_filter["platform"]:
  241. scope_clauses.append("p_scope.platform = ANY(%s)")
  242. scope_params.append(scope_filter["platform"])
  243. scope_where_sql = f" AND {' AND '.join(scope_clauses)}" if scope_clauses else ""
  244. rows = _fetch_all(
  245. f"""
  246. WITH selected AS (
  247. SELECT
  248. i.id,
  249. i.execution_id,
  250. i.mining_config_id,
  251. cfg.execution_id AS mining_config_execution_id,
  252. cfg.scope AS mining_config_scope,
  253. cfg.name AS mining_config_name,
  254. cfg.algorithm AS mining_config_algorithm,
  255. cfg.params::text AS mining_config_params,
  256. cfg.params ->> 'dimension_mode' AS mining_config_dimension_mode,
  257. COALESCE(cfg.params ->> 'target_depth', cfg.params ->> 'depth') AS mining_config_target_depth,
  258. i.scope,
  259. i.combination_type,
  260. i.item_count,
  261. i.support,
  262. i.absolute_support,
  263. i.dimensions::text AS dimensions,
  264. i.is_cross_point,
  265. i.is_cross_layer
  266. FROM pattern_itemset i
  267. JOIN pattern_mining_config cfg
  268. ON cfg.id = i.mining_config_id
  269. AND cfg.execution_id = i.execution_id
  270. AND cfg.scope = %s
  271. WHERE i.execution_id = %s
  272. AND i.scope = %s
  273. AND i.id = ANY(%s)
  274. )
  275. SELECT
  276. s.*,
  277. COALESCE(
  278. (
  279. SELECT array_agg(p_all.post_id ORDER BY p_all.post_id)
  280. FROM pattern_itemset_post p_all
  281. WHERE p_all.itemset_id = s.id
  282. AND p_all.execution_id = s.execution_id
  283. ),
  284. ARRAY[]::varchar[]
  285. ) AS global_matched_post_ids,
  286. COALESCE(
  287. (
  288. SELECT array_agg(DISTINCT p_scope.post_id ORDER BY p_scope.post_id)
  289. FROM pattern_itemset_post p_scope
  290. JOIN post post_scope
  291. ON post_scope.post_id = p_scope.post_id
  292. WHERE p_scope.itemset_id = s.id
  293. AND p_scope.execution_id = s.execution_id
  294. {scope_where_sql.replace('p_scope.merge_leve2', 'post_scope.merge_leve2').replace('p_scope.platform', 'post_scope.platform')}
  295. ),
  296. ARRAY[]::varchar[]
  297. ) AS filtered_matched_post_ids
  298. FROM selected s
  299. ORDER BY s.id
  300. """,
  301. tuple([TOPIC_SCOPE, int(execution_id), TOPIC_SCOPE, clean_ids, *scope_params]),
  302. )
  303. result: list[dict[str, Any]] = []
  304. for row in rows:
  305. itemset = dict(row)
  306. for key in (
  307. "id",
  308. "execution_id",
  309. "mining_config_id",
  310. "mining_config_execution_id",
  311. "item_count",
  312. "absolute_support",
  313. ):
  314. if itemset.get(key) is not None:
  315. itemset[key] = int(itemset[key])
  316. itemset["support"] = float(itemset["support"]) if itemset.get("support") is not None else None
  317. itemset["is_cross_point"] = bool(itemset.get("is_cross_point"))
  318. itemset["is_cross_layer"] = bool(itemset.get("is_cross_layer"))
  319. itemset["dimensions"] = _jsonish_to_list(itemset.get("dimensions"))
  320. if isinstance(itemset.get("mining_config_params"), str):
  321. try:
  322. itemset["mining_config_params"] = json.loads(itemset["mining_config_params"])
  323. except json.JSONDecodeError:
  324. pass
  325. global_post_ids = _to_post_id_list(itemset.get("global_matched_post_ids"))
  326. filtered_post_ids = _to_post_id_list(itemset.get("filtered_matched_post_ids"))
  327. itemset["global_matched_post_ids"] = global_post_ids
  328. itemset["filtered_matched_post_ids"] = filtered_post_ids if scoped else []
  329. itemset["filtered_absolute_support"] = len(filtered_post_ids) if scoped else None
  330. itemset["scoped_post_count"] = len(filtered_post_ids) if scoped else None
  331. itemset["scope_filter"] = scope_filter if scoped else {}
  332. itemset["matched_post_ids"] = filtered_post_ids if scoped else global_post_ids
  333. result.append(itemset)
  334. by_id = {itemset["id"]: itemset for itemset in result}
  335. return [by_id[itemset_id] for itemset_id in clean_ids if itemset_id in by_id]
  336. def query_itemset_items_with_categories(
  337. execution_id: int,
  338. itemset_ids: Iterable[Any],
  339. ) -> list[dict[str, Any]]:
  340. """Read PG itemset items and their exact category tree nodes."""
  341. clean_ids = _to_int_list(itemset_ids or [])
  342. if not clean_ids:
  343. return []
  344. rows = _fetch_all(
  345. """
  346. SELECT
  347. ii.id AS itemset_item_id,
  348. ii.itemset_id,
  349. ii.layer,
  350. ii.point_type,
  351. ii.dimension,
  352. ii.category_id,
  353. ii.category_path,
  354. ii.element_name,
  355. ii.element_id,
  356. ii.post_count,
  357. c.id AS bound_category_id,
  358. c.execution_id AS category_execution_id,
  359. c.source_stable_id AS category_source_stable_id,
  360. c.source_type AS category_source_type,
  361. c.name AS category_name,
  362. c.path AS category_full_path,
  363. c.level AS category_level,
  364. c.parent_id AS category_parent_id,
  365. c.parent_source_stable_id AS category_parent_source_stable_id,
  366. c.classified_as AS category_nature
  367. FROM pattern_itemset_item ii
  368. JOIN pattern_itemset i
  369. ON i.id = ii.itemset_id
  370. AND i.execution_id = %s
  371. AND i.scope = %s
  372. LEFT JOIN pattern_mining_category c
  373. ON c.id = ii.category_id
  374. AND c.execution_id = i.execution_id
  375. WHERE ii.itemset_id = ANY(%s)
  376. ORDER BY ii.itemset_id, ii.id
  377. """,
  378. (int(execution_id), TOPIC_SCOPE, clean_ids),
  379. )
  380. result: list[dict[str, Any]] = []
  381. for row in rows:
  382. item = dict(row)
  383. for key in (
  384. "itemset_item_id",
  385. "itemset_id",
  386. "category_id",
  387. "element_id",
  388. "post_count",
  389. "bound_category_id",
  390. "category_execution_id",
  391. "category_source_stable_id",
  392. "category_level",
  393. "category_parent_id",
  394. "category_parent_source_stable_id",
  395. ):
  396. if item.get(key) is not None:
  397. item[key] = int(item[key])
  398. item["category_found"] = item.get("bound_category_id") is not None
  399. result.append(item)
  400. return result
  401. def query_element_bindings_for_items(
  402. execution_id: int,
  403. itemset_items: Sequence[Mapping[str, Any]],
  404. post_ids: Iterable[Any] | None = None,
  405. limit_per_item: int = 200,
  406. ) -> list[dict[str, Any]]:
  407. """Bind itemset items to real PG Pattern V2 source-post elements."""
  408. clean_post_ids = _to_post_id_list(post_ids)
  409. if not itemset_items:
  410. return []
  411. bindings: list[dict[str, Any]] = []
  412. with _connect_readonly() as conn:
  413. with conn.cursor(cursor_factory=RealDictCursor) as cursor:
  414. for raw_item in itemset_items:
  415. item = dict(raw_item)
  416. category_id = item.get("bound_category_id") or item.get("category_id")
  417. if category_id is None:
  418. continue
  419. clauses = [
  420. "execution_id = %s",
  421. "category_id = %s",
  422. "source_table = %s",
  423. ]
  424. params: list[Any] = [int(execution_id), int(category_id), TOPIC_ELEMENT_SOURCE_TABLE]
  425. if item.get("point_type"):
  426. clauses.append("point_type = %s")
  427. params.append(item["point_type"])
  428. if _is_element_type_dimension(item.get("dimension")):
  429. clauses.append("element_type = %s")
  430. params.append(item["dimension"])
  431. if item.get("element_name"):
  432. clauses.append("name = %s")
  433. params.append(item["element_name"])
  434. if clean_post_ids:
  435. clauses.append("post_id = ANY(%s)")
  436. params.append(clean_post_ids)
  437. where_sql = " AND ".join(clauses)
  438. count_row = _cursor_fetch_one(
  439. cursor,
  440. f"""
  441. SELECT
  442. COUNT(*) AS matched_element_count,
  443. COUNT(DISTINCT post_id) AS matched_post_count
  444. FROM pattern_mining_element
  445. WHERE {where_sql}
  446. """,
  447. tuple(params),
  448. ) or {}
  449. post_rows = _cursor_fetch_all(
  450. cursor,
  451. f"""
  452. SELECT DISTINCT post_id
  453. FROM pattern_mining_element
  454. WHERE {where_sql}
  455. ORDER BY post_id
  456. LIMIT %s
  457. """,
  458. tuple(params + [int(limit_per_item)]),
  459. )
  460. sample_rows = _cursor_fetch_all(
  461. cursor,
  462. f"""
  463. SELECT
  464. id,
  465. post_id,
  466. source_table,
  467. source_element_id,
  468. point_type,
  469. point_text,
  470. element_type,
  471. name,
  472. category_id,
  473. category_path,
  474. topic_point_id
  475. FROM pattern_mining_element
  476. WHERE {where_sql}
  477. ORDER BY post_id, id
  478. LIMIT %s
  479. """,
  480. tuple(params + [min(int(limit_per_item), 20)]),
  481. )
  482. bindings.append(
  483. {
  484. "itemset_id": item.get("itemset_id"),
  485. "itemset_item_id": item.get("itemset_item_id"),
  486. "category_id": int(category_id),
  487. "point_type": item.get("point_type"),
  488. "dimension": item.get("dimension"),
  489. "element_name": item.get("element_name"),
  490. "matched_element_count": int(count_row.get("matched_element_count") or 0),
  491. "matched_post_count": int(count_row.get("matched_post_count") or 0),
  492. "matched_post_ids": _to_post_id_list([row["post_id"] for row in post_rows]),
  493. "sample_elements": sample_rows,
  494. }
  495. )
  496. return bindings
  497. def query_video_ids_by_names(execution_id: int, names: Iterable[str]) -> list[str]:
  498. """Resolve names to PG source post IDs by category name/path leaf or element name."""
  499. clean_names = _clean_names(names)
  500. if not clean_names:
  501. return []
  502. rows = _fetch_all(
  503. """
  504. WITH target_categories AS (
  505. SELECT id
  506. FROM pattern_mining_category
  507. WHERE execution_id = %s
  508. AND (
  509. name = ANY(%s)
  510. OR regexp_replace(path, '^.*/', '') = ANY(%s)
  511. )
  512. ),
  513. matched_elements AS (
  514. SELECT post_id
  515. FROM pattern_mining_element
  516. WHERE execution_id = %s
  517. AND source_table = %s
  518. AND (
  519. name = ANY(%s)
  520. OR category_id IN (SELECT id FROM target_categories)
  521. )
  522. )
  523. SELECT DISTINCT post_id
  524. FROM matched_elements
  525. WHERE post_id IS NOT NULL
  526. ORDER BY post_id
  527. """,
  528. (int(execution_id), clean_names, clean_names, int(execution_id), TOPIC_ELEMENT_SOURCE_TABLE, clean_names),
  529. )
  530. return _to_post_id_list([row["post_id"] for row in rows])
  531. def query_category_level(execution_id: int, name: str) -> int | None:
  532. clean_name = str(name).strip() if name is not None else ""
  533. if not clean_name:
  534. return None
  535. row = _fetch_one(
  536. """
  537. SELECT level
  538. FROM pattern_mining_category
  539. WHERE execution_id = %s
  540. AND (name = %s OR regexp_replace(path, '^.*/', '') = %s)
  541. ORDER BY id DESC
  542. LIMIT 1
  543. """,
  544. (int(execution_id), clean_name, clean_name),
  545. )
  546. return int(row["level"]) if row and row.get("level") is not None else None
  547. def query_case_ids_by_post_ids(post_ids: Iterable[Any]) -> list[dict[str, Any]]:
  548. """PG Pattern V2 has post_id semantics for MVP exact evidence.
  549. DemandAgent no longer reads the old MySQL decode-case table in this PG-only
  550. path. `case_ids` is filled from validated post IDs by the evidence builder;
  551. `decode_case_ids` remains an optional empty compatibility field.
  552. """
  553. return []
  554. def query_categories(
  555. execution_id: int,
  556. *,
  557. source_type: str | None = None,
  558. keyword: str | None = None,
  559. category_ids: Iterable[Any] | None = None,
  560. limit: int | None = None,
  561. ) -> list[dict[str, Any]]:
  562. clauses = ["execution_id = %s"]
  563. params: list[Any] = [int(execution_id)]
  564. if source_type:
  565. clauses.append("source_type = %s")
  566. params.append(source_type)
  567. if keyword:
  568. clauses.append("(name ILIKE %s OR path ILIKE %s)")
  569. like = f"%{keyword}%"
  570. params.extend([like, like])
  571. clean_ids = _to_int_list(category_ids or [])
  572. if clean_ids:
  573. clauses.append("id = ANY(%s)")
  574. params.append(clean_ids)
  575. limit_sql = "LIMIT %s" if limit else ""
  576. if limit:
  577. params.append(int(limit))
  578. return _fetch_all(
  579. f"""
  580. SELECT
  581. id,
  582. execution_id,
  583. source_stable_id,
  584. source_type,
  585. name,
  586. description,
  587. path,
  588. level,
  589. parent_id,
  590. parent_source_stable_id,
  591. element_count,
  592. classified_as AS category_nature
  593. FROM pattern_mining_category
  594. WHERE {" AND ".join(clauses)}
  595. ORDER BY source_type, path, id
  596. {limit_sql}
  597. """,
  598. tuple(params),
  599. )
  600. def query_elements(
  601. execution_id: int,
  602. *,
  603. keyword: str | None = None,
  604. element_type: str | None = None,
  605. post_ids: Iterable[Any] | None = None,
  606. category_ids: Iterable[Any] | None = None,
  607. merge_leve2: Any = None,
  608. platform: Any = None,
  609. limit: int | None = None,
  610. ) -> list[dict[str, Any]]:
  611. clauses = [
  612. "e.execution_id = %s",
  613. "e.source_table = %s",
  614. ]
  615. params: list[Any] = [int(execution_id), TOPIC_ELEMENT_SOURCE_TABLE]
  616. if keyword:
  617. clauses.append("e.name ILIKE %s")
  618. params.append(f"%{keyword}%")
  619. if element_type:
  620. clauses.append("e.element_type = %s")
  621. params.append(element_type)
  622. clean_posts = _to_post_id_list(post_ids)
  623. if post_ids is not None and not clean_posts:
  624. return []
  625. if clean_posts:
  626. clauses.append("e.post_id = ANY(%s)")
  627. params.append(clean_posts)
  628. clean_categories = _to_int_list(category_ids or [])
  629. if clean_categories:
  630. clauses.append("e.category_id = ANY(%s)")
  631. params.append(clean_categories)
  632. scope_filter = _scope_filter_payload(merge_leve2=merge_leve2, platform=platform)
  633. scope_active = _scope_is_active(scope_filter)
  634. join_post_sql = ""
  635. if scope_active:
  636. join_post_sql = "JOIN post post_scope ON post_scope.post_id = e.post_id"
  637. if scope_filter["merge_leve2"]:
  638. clauses.append("post_scope.merge_leve2 = ANY(%s)")
  639. params.append(scope_filter["merge_leve2"])
  640. if scope_filter["platform"]:
  641. clauses.append("post_scope.platform = ANY(%s)")
  642. params.append(scope_filter["platform"])
  643. limit_sql = "LIMIT %s" if limit else ""
  644. if limit:
  645. params.append(int(limit))
  646. return _fetch_all(
  647. f"""
  648. SELECT
  649. e.id,
  650. e.execution_id,
  651. e.post_id,
  652. e.source_table,
  653. e.source_element_id,
  654. e.element_type,
  655. e.element_sub_type,
  656. e.name,
  657. e.description,
  658. e.category_id,
  659. e.category_path,
  660. e.point_type,
  661. e.point_text,
  662. e.topic_point_id
  663. FROM pattern_mining_element e
  664. {join_post_sql}
  665. WHERE {" AND ".join(clauses)}
  666. ORDER BY e.post_id, e.id
  667. {limit_sql}
  668. """,
  669. tuple(params),
  670. )
  671. def query_source_elements(
  672. execution_id: int,
  673. *,
  674. element_names: Iterable[Any] | None = None,
  675. category_ids: Iterable[Any] | None = None,
  676. category_names: Iterable[Any] | None = None,
  677. category_paths: Iterable[Any] | None = None,
  678. element_types: Iterable[Any] | None = None,
  679. post_ids: Iterable[Any] | None = None,
  680. merge_leve2: Any = None,
  681. platform: Any = None,
  682. limit: int = 20000,
  683. ) -> list[dict[str, Any]]:
  684. """Read source elements for DB-validating non-itemset evidence sources."""
  685. clauses = [
  686. "e.execution_id = %s",
  687. "e.source_table = %s",
  688. ]
  689. params: list[Any] = [int(execution_id), TOPIC_ELEMENT_SOURCE_TABLE]
  690. clean_names = _clean_names(str(name) for name in _jsonish_to_list(element_names))
  691. if clean_names:
  692. clauses.append("e.name = ANY(%s)")
  693. params.append(clean_names)
  694. clean_category_ids = _to_int_list(category_ids or [])
  695. if clean_category_ids:
  696. clauses.append("e.category_id = ANY(%s)")
  697. params.append(clean_category_ids)
  698. clean_category_names = _clean_names(str(name) for name in _jsonish_to_list(category_names))
  699. if clean_category_names:
  700. clauses.append("(c.name = ANY(%s) OR regexp_replace(e.category_path, '^.*/', '') = ANY(%s))")
  701. params.extend([clean_category_names, clean_category_names])
  702. clean_category_paths = _clean_names(str(path) for path in _jsonish_to_list(category_paths))
  703. if clean_category_paths:
  704. clauses.append("(c.path = ANY(%s) OR e.category_path = ANY(%s))")
  705. params.extend([clean_category_paths, clean_category_paths])
  706. clean_element_types = _clean_names(str(kind) for kind in _jsonish_to_list(element_types))
  707. if clean_element_types:
  708. clauses.append("e.element_type = ANY(%s)")
  709. params.append(clean_element_types)
  710. clean_post_ids = _to_post_id_list(post_ids)
  711. if clean_post_ids:
  712. clauses.append("e.post_id = ANY(%s)")
  713. params.append(clean_post_ids)
  714. scope_filter = _scope_filter_payload(merge_leve2=merge_leve2, platform=platform)
  715. scope_active = _scope_is_active(scope_filter)
  716. join_post_sql = ""
  717. if scope_active:
  718. join_post_sql = "JOIN post post_scope ON post_scope.post_id = e.post_id"
  719. if scope_filter["merge_leve2"]:
  720. clauses.append("post_scope.merge_leve2 = ANY(%s)")
  721. params.append(scope_filter["merge_leve2"])
  722. if scope_filter["platform"]:
  723. clauses.append("post_scope.platform = ANY(%s)")
  724. params.append(scope_filter["platform"])
  725. params.append(int(limit))
  726. return _fetch_all(
  727. f"""
  728. SELECT
  729. e.id,
  730. e.execution_id,
  731. e.post_id,
  732. e.source_table,
  733. e.source_element_id,
  734. e.element_type,
  735. e.element_sub_type,
  736. e.name,
  737. e.description,
  738. e.category_id,
  739. e.category_path,
  740. e.point_type,
  741. e.point_text,
  742. e.topic_point_id,
  743. c.name AS category_name,
  744. c.path AS category_full_path,
  745. c.source_type AS category_source_type,
  746. c.level AS category_level,
  747. c.parent_id AS category_parent_id,
  748. c.classified_as AS category_nature
  749. FROM pattern_mining_element e
  750. LEFT JOIN pattern_mining_category c
  751. ON c.id = e.category_id
  752. AND c.execution_id = e.execution_id
  753. {join_post_sql}
  754. WHERE {" AND ".join(clauses)}
  755. ORDER BY e.post_id, e.id
  756. LIMIT %s
  757. """,
  758. tuple(params),
  759. )
  760. def query_topic_itemsets(
  761. execution_id: int,
  762. *,
  763. category_ids: Iterable[Any] | None = None,
  764. dimension_mode: str | None = None,
  765. min_support: int | None = None,
  766. min_item_count: int | None = None,
  767. max_item_count: int | None = None,
  768. sort_by: str = "absolute_support",
  769. limit: int = 100,
  770. merge_leve2: Any = None,
  771. platform: Any = None,
  772. ) -> list[dict[str, Any]]:
  773. scope_filter = _scope_filter_payload(merge_leve2=merge_leve2, platform=platform)
  774. scoped = _scope_is_active(scope_filter)
  775. clauses = [
  776. "i.execution_id = %s",
  777. "i.scope = %s",
  778. ]
  779. where_params: list[Any] = [int(execution_id), TOPIC_SCOPE]
  780. clean_category_ids = _to_int_list(category_ids or [])
  781. for category_id in clean_category_ids:
  782. clauses.append(
  783. """
  784. EXISTS (
  785. SELECT 1
  786. FROM pattern_itemset_item ii_filter
  787. WHERE ii_filter.itemset_id = i.id
  788. AND ii_filter.category_id = %s
  789. )
  790. """
  791. )
  792. where_params.append(category_id)
  793. if dimension_mode:
  794. clauses.append("cfg.params ->> 'dimension_mode' = %s")
  795. where_params.append(dimension_mode)
  796. if min_support is not None:
  797. if scoped:
  798. clauses.append("COALESCE(scoped_posts.filtered_absolute_support, 0) >= %s")
  799. else:
  800. clauses.append("i.absolute_support >= %s")
  801. where_params.append(int(min_support))
  802. if min_item_count is not None:
  803. clauses.append("i.item_count >= %s")
  804. where_params.append(int(min_item_count))
  805. if max_item_count is not None:
  806. clauses.append("i.item_count <= %s")
  807. where_params.append(int(max_item_count))
  808. if scoped:
  809. order_sql = {
  810. "item_count": "i.item_count DESC NULLS LAST, COALESCE(scoped_posts.filtered_absolute_support, 0) DESC",
  811. "support": "COALESCE(scoped_posts.filtered_absolute_support, 0) DESC, i.support DESC NULLS LAST",
  812. "absolute_support": "COALESCE(scoped_posts.filtered_absolute_support, 0) DESC, i.absolute_support DESC NULLS LAST",
  813. }.get(
  814. str(sort_by or "absolute_support"),
  815. "COALESCE(scoped_posts.filtered_absolute_support, 0) DESC, i.absolute_support DESC NULLS LAST",
  816. )
  817. else:
  818. order_sql = {
  819. "support": "i.support DESC NULLS LAST, i.absolute_support DESC NULLS LAST",
  820. "item_count": "i.item_count DESC NULLS LAST, i.absolute_support DESC NULLS LAST",
  821. "absolute_support": "i.absolute_support DESC NULLS LAST, i.support DESC NULLS LAST",
  822. }.get(str(sort_by or "absolute_support"), "i.absolute_support DESC NULLS LAST, i.support DESC NULLS LAST")
  823. scope_clauses: list[str] = []
  824. scope_params: list[Any] = []
  825. if scope_filter["merge_leve2"]:
  826. scope_clauses.append("post_scope.merge_leve2 = ANY(%s)")
  827. scope_params.append(scope_filter["merge_leve2"])
  828. if scope_filter["platform"]:
  829. scope_clauses.append("post_scope.platform = ANY(%s)")
  830. scope_params.append(scope_filter["platform"])
  831. scope_where_sql = f" AND {' AND '.join(scope_clauses)}" if scope_clauses else ""
  832. if scoped:
  833. return _fetch_all(
  834. f"""
  835. WITH scoped_posts AS (
  836. SELECT
  837. p_scope.itemset_id,
  838. COALESCE(
  839. array_agg(DISTINCT p_scope.post_id ORDER BY p_scope.post_id),
  840. ARRAY[]::varchar[]
  841. ) AS filtered_matched_post_ids,
  842. COUNT(DISTINCT p_scope.post_id) AS filtered_absolute_support
  843. FROM pattern_itemset_post p_scope
  844. JOIN post post_scope
  845. ON post_scope.post_id = p_scope.post_id
  846. WHERE p_scope.execution_id = %s
  847. {scope_where_sql}
  848. GROUP BY p_scope.itemset_id
  849. )
  850. SELECT
  851. i.id,
  852. i.execution_id,
  853. i.mining_config_id,
  854. cfg.scope AS mining_config_scope,
  855. cfg.name AS mining_config_name,
  856. cfg.params AS mining_config_params,
  857. cfg.params ->> 'dimension_mode' AS dimension_mode,
  858. COALESCE(cfg.params ->> 'target_depth', cfg.params ->> 'depth') AS target_depth,
  859. i.scope,
  860. i.combination_type,
  861. i.item_count,
  862. i.support,
  863. i.absolute_support,
  864. scoped_posts.filtered_matched_post_ids,
  865. scoped_posts.filtered_absolute_support,
  866. scoped_posts.filtered_absolute_support AS scoped_post_count,
  867. i.dimensions,
  868. i.is_cross_point,
  869. i.is_cross_layer,
  870. %s::jsonb AS scope_filter
  871. FROM pattern_itemset i
  872. JOIN pattern_mining_config cfg
  873. ON cfg.id = i.mining_config_id
  874. AND cfg.execution_id = i.execution_id
  875. AND cfg.scope = %s
  876. JOIN scoped_posts
  877. ON scoped_posts.itemset_id = i.id
  878. WHERE {" AND ".join(clauses)}
  879. ORDER BY {order_sql}, i.id
  880. LIMIT %s
  881. """,
  882. tuple([
  883. int(execution_id),
  884. *scope_params,
  885. json.dumps(scope_filter, ensure_ascii=False),
  886. TOPIC_SCOPE,
  887. *where_params,
  888. int(limit),
  889. ]),
  890. )
  891. return _fetch_all(
  892. f"""
  893. SELECT
  894. i.id,
  895. i.execution_id,
  896. i.mining_config_id,
  897. cfg.scope AS mining_config_scope,
  898. cfg.name AS mining_config_name,
  899. cfg.params AS mining_config_params,
  900. cfg.params ->> 'dimension_mode' AS dimension_mode,
  901. COALESCE(cfg.params ->> 'target_depth', cfg.params ->> 'depth') AS target_depth,
  902. i.scope,
  903. i.combination_type,
  904. i.item_count,
  905. i.support,
  906. i.absolute_support,
  907. NULL::varchar[] AS filtered_matched_post_ids,
  908. NULL::bigint AS filtered_absolute_support,
  909. NULL::bigint AS scoped_post_count,
  910. i.dimensions,
  911. i.is_cross_point,
  912. i.is_cross_layer,
  913. %s::jsonb AS scope_filter
  914. FROM pattern_itemset i
  915. JOIN pattern_mining_config cfg
  916. ON cfg.id = i.mining_config_id
  917. AND cfg.execution_id = i.execution_id
  918. AND cfg.scope = %s
  919. WHERE {" AND ".join(clauses)}
  920. ORDER BY {order_sql}, i.id
  921. LIMIT %s
  922. """,
  923. tuple([
  924. json.dumps({}, ensure_ascii=False),
  925. TOPIC_SCOPE,
  926. *where_params,
  927. int(limit),
  928. ]),
  929. )
  930. def query_seed_points_for_itemsets(
  931. execution_id: int,
  932. itemset_ids: Iterable[Any],
  933. matched_post_ids: Iterable[Any],
  934. *,
  935. top_k: int = 30,
  936. ) -> list[dict[str, Any]]:
  937. """Rank searchable source points for scoped itemset support posts.
  938. The result is intentionally derived from the same PG element schema used by
  939. `element_bindings.sample_elements`, but it is a separate search-seed pool:
  940. it spans all validated support posts and ranks point_text by distinct-post
  941. coverage.
  942. """
  943. clean_itemset_ids = _to_int_list(itemset_ids or [])
  944. clean_post_ids = _to_post_id_list(list(matched_post_ids or []))
  945. if not clean_itemset_ids or not clean_post_ids:
  946. return []
  947. rows = _fetch_all(
  948. """
  949. WITH item_cats AS (
  950. SELECT
  951. ii.id AS itemset_item_id,
  952. ii.itemset_id,
  953. ii.point_type,
  954. ii.dimension,
  955. ii.category_id,
  956. ii.category_path,
  957. ii.element_name
  958. FROM pattern_itemset_item ii
  959. JOIN pattern_itemset i
  960. ON i.id = ii.itemset_id
  961. AND i.execution_id = %s
  962. AND i.scope = %s
  963. WHERE ii.itemset_id = ANY(%s)
  964. AND ii.category_id IS NOT NULL
  965. )
  966. SELECT
  967. e.id,
  968. e.post_id,
  969. e.source_table,
  970. e.source_element_id,
  971. e.point_type,
  972. e.point_text,
  973. e.element_type,
  974. e.name,
  975. e.category_id,
  976. e.category_path,
  977. e.topic_point_id,
  978. ic.itemset_id AS matched_itemset_id,
  979. ic.itemset_item_id AS matched_itemset_item_id,
  980. ic.category_id AS matched_category_id
  981. FROM pattern_mining_element e
  982. JOIN item_cats ic
  983. ON ic.category_id = e.category_id
  984. AND (ic.point_type IS NULL OR ic.point_type = e.point_type)
  985. AND (ic.dimension NOT IN ('实质', '形式', '意图') OR ic.dimension = e.element_type)
  986. AND (ic.element_name IS NULL OR ic.element_name = e.name)
  987. WHERE e.execution_id = %s
  988. AND e.source_table = %s
  989. AND e.post_id = ANY(%s)
  990. AND e.point_type IN ('灵感点', '目的点')
  991. AND e.point_text IS NOT NULL
  992. AND e.point_text <> ''
  993. ORDER BY e.point_text, e.point_type, e.post_id, e.id
  994. """,
  995. (
  996. int(execution_id),
  997. TOPIC_SCOPE,
  998. clean_itemset_ids,
  999. int(execution_id),
  1000. TOPIC_ELEMENT_SOURCE_TABLE,
  1001. clean_post_ids,
  1002. ),
  1003. )
  1004. return _rank_query_seed_point_rows(rows, top_k=top_k)
  1005. def query_seed_points_for_sources(
  1006. execution_id: int,
  1007. matched_post_ids: Iterable[Any],
  1008. *,
  1009. category_ids: Iterable[Any] | None = None,
  1010. top_k: int = 30,
  1011. ) -> list[dict[str, Any]]:
  1012. """Rank searchable source points for any DB-validated evidence source."""
  1013. clean_post_ids = _to_post_id_list(list(matched_post_ids or []))
  1014. if not clean_post_ids:
  1015. return []
  1016. clauses = [
  1017. "e.execution_id = %s",
  1018. "e.source_table = %s",
  1019. "e.post_id = ANY(%s)",
  1020. "e.point_type IN ('灵感点', '目的点')",
  1021. "e.point_text IS NOT NULL",
  1022. "e.point_text <> ''",
  1023. ]
  1024. params: list[Any] = [int(execution_id), TOPIC_ELEMENT_SOURCE_TABLE, clean_post_ids]
  1025. clean_category_ids = _to_int_list(category_ids or [])
  1026. if clean_category_ids:
  1027. clauses.append("e.category_id = ANY(%s)")
  1028. params.append(clean_category_ids)
  1029. rows = _fetch_all(
  1030. f"""
  1031. SELECT
  1032. e.id,
  1033. e.post_id,
  1034. e.source_table,
  1035. e.source_element_id,
  1036. e.point_type,
  1037. e.point_text,
  1038. e.element_type,
  1039. e.name,
  1040. e.category_id,
  1041. e.category_path,
  1042. e.topic_point_id,
  1043. e.category_id AS matched_category_id
  1044. FROM pattern_mining_element e
  1045. WHERE {" AND ".join(clauses)}
  1046. ORDER BY e.point_text, e.point_type, e.post_id, e.id
  1047. """,
  1048. tuple(params),
  1049. )
  1050. return _rank_query_seed_point_rows(rows, top_k=top_k)
  1051. def _rank_query_seed_point_rows(rows: list[dict[str, Any]], *, top_k: int = 30) -> list[dict[str, Any]]:
  1052. grouped: dict[tuple[str, str], dict[str, Any]] = {}
  1053. for row in rows:
  1054. point_text = str(row.get("point_text") or "").strip()
  1055. point_type = str(row.get("point_type") or "").strip()
  1056. if not point_text or point_type not in {"灵感点", "目的点"}:
  1057. continue
  1058. key = (point_text, point_type)
  1059. group = grouped.setdefault(
  1060. key,
  1061. {
  1062. "representative": dict(row),
  1063. "post_ids": set(),
  1064. "matched_category_ids": set(),
  1065. "matched_itemset_item_ids": set(),
  1066. },
  1067. )
  1068. if row.get("post_id") is not None:
  1069. group["post_ids"].add(str(row["post_id"]))
  1070. if row.get("matched_category_id") is not None:
  1071. group["matched_category_ids"].add(int(row["matched_category_id"]))
  1072. if row.get("matched_itemset_item_id") is not None:
  1073. group["matched_itemset_item_ids"].add(int(row["matched_itemset_item_id"]))
  1074. ranked = sorted(
  1075. grouped.items(),
  1076. key=lambda item: (-len(item[1]["post_ids"]), item[0][0], item[0][1]),
  1077. )
  1078. result: list[dict[str, Any]] = []
  1079. for rank, ((point_text, point_type), group) in enumerate(ranked[: max(int(top_k or 30), 0)], start=1):
  1080. representative = dict(group["representative"])
  1081. representative.pop("matched_itemset_id", None)
  1082. representative.pop("matched_itemset_item_id", None)
  1083. representative.pop("matched_category_id", None)
  1084. representative["point_text"] = point_text
  1085. representative["point_type"] = point_type
  1086. representative["coverage_post_count"] = len(group["post_ids"])
  1087. representative["rank"] = rank
  1088. representative["matched_category_ids"] = sorted(group["matched_category_ids"])
  1089. representative["matched_itemset_item_ids"] = sorted(group["matched_itemset_item_ids"])
  1090. result.append(representative)
  1091. return result
  1092. def query_weight_score_rows(
  1093. execution_id: int,
  1094. *,
  1095. level: str,
  1096. dimension: str,
  1097. limit: int = 5000,
  1098. ) -> list[dict[str, Any]]:
  1099. """Aggregate PG topic elements into old weight-score JSON shapes."""
  1100. if level == "元素":
  1101. return _fetch_all(
  1102. """
  1103. SELECT
  1104. name,
  1105. element_type,
  1106. category_id,
  1107. category_path,
  1108. COUNT(*) AS occurrence_count,
  1109. COUNT(DISTINCT post_id) AS post_count
  1110. FROM pattern_mining_element
  1111. WHERE execution_id = %s
  1112. AND source_table = %s
  1113. AND element_type = %s
  1114. AND name IS NOT NULL
  1115. AND name <> ''
  1116. GROUP BY name, element_type, category_id, category_path
  1117. ORDER BY COUNT(DISTINCT post_id) DESC, COUNT(*) DESC, name
  1118. LIMIT %s
  1119. """,
  1120. (int(execution_id), TOPIC_ELEMENT_SOURCE_TABLE, dimension, int(limit)),
  1121. )
  1122. if level == "分类":
  1123. return _fetch_all(
  1124. """
  1125. SELECT
  1126. COALESCE(c.name, regexp_replace(e.category_path, '^.*/', '')) AS category,
  1127. e.category_id,
  1128. e.category_path,
  1129. e.element_type,
  1130. COUNT(*) AS occurrence_count,
  1131. COUNT(DISTINCT e.post_id) AS post_count,
  1132. COALESCE(MAX(c.level), array_length(string_to_array(trim(both '/' from e.category_path), '/'), 1)) AS level
  1133. FROM pattern_mining_element e
  1134. LEFT JOIN pattern_mining_category c
  1135. ON c.id = e.category_id
  1136. AND c.execution_id = e.execution_id
  1137. WHERE e.execution_id = %s
  1138. AND e.source_table = %s
  1139. AND e.element_type = %s
  1140. AND e.category_id IS NOT NULL
  1141. AND e.category_path IS NOT NULL
  1142. GROUP BY e.category_id, e.category_path, e.element_type, c.name
  1143. ORDER BY COUNT(DISTINCT e.post_id) DESC, COUNT(*) DESC, e.category_path
  1144. LIMIT %s
  1145. """,
  1146. (int(execution_id), TOPIC_ELEMENT_SOURCE_TABLE, dimension, int(limit)),
  1147. )
  1148. raise ValueError(f"unsupported weight score level: {level}")