pg_pattern_repository.py 44 KB

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