pg_pattern_repository.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804
  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. _READONLY_SQL_RE = re.compile(r"^\s*(select|with|show|explain)\b", re.IGNORECASE | re.DOTALL)
  30. _BLOCKED_SQL_RE = re.compile(
  31. r"\b(insert|update|delete|create|alter|drop|truncate|copy|grant|revoke|merge|call)\b",
  32. re.IGNORECASE,
  33. )
  34. def _load_dotenv_once() -> None:
  35. try:
  36. from dotenv import load_dotenv
  37. except Exception:
  38. return
  39. load_dotenv()
  40. def _resolve_dsn() -> str:
  41. _load_dotenv_once()
  42. dsn = os.getenv("DEMAND_PATTERN_PG_DSN") or os.getenv("PGVECTOR_DSN")
  43. if not dsn:
  44. raise RuntimeError("PG Pattern V2 DSN missing: set DEMAND_PATTERN_PG_DSN or PGVECTOR_DSN")
  45. return dsn
  46. def _assert_readonly_sql(sql: str) -> None:
  47. if not _READONLY_SQL_RE.search(sql):
  48. raise RuntimeError("PG Pattern repository only allows SELECT/SHOW/EXPLAIN queries")
  49. if _BLOCKED_SQL_RE.search(sql):
  50. raise RuntimeError("PG Pattern repository blocked a non-read-only SQL keyword")
  51. @contextmanager
  52. def _connect_readonly():
  53. if psycopg2 is None:
  54. raise RuntimeError("psycopg2 is required for PG Pattern V2 reads")
  55. conn = psycopg2.connect(_resolve_dsn())
  56. try:
  57. conn.set_session(readonly=True, autocommit=True)
  58. yield conn
  59. finally:
  60. conn.close()
  61. def _fetch_all(sql: str, params: Sequence[Any] | Mapping[str, Any] | None = None) -> list[dict[str, Any]]:
  62. _assert_readonly_sql(sql)
  63. with _connect_readonly() as conn:
  64. with conn.cursor(cursor_factory=RealDictCursor) as cursor:
  65. cursor.execute(sql, params)
  66. return [_row_to_dict(row) for row in cursor.fetchall()]
  67. def _fetch_one(sql: str, params: Sequence[Any] | Mapping[str, Any] | None = None) -> dict[str, Any] | None:
  68. rows = _fetch_all(sql, params)
  69. return rows[0] if rows else None
  70. def _cursor_fetch_all(cursor: Any, sql: str, params: Sequence[Any] | Mapping[str, Any] | None = None) -> list[dict[str, Any]]:
  71. _assert_readonly_sql(sql)
  72. cursor.execute(sql, params)
  73. return [_row_to_dict(row) for row in cursor.fetchall()]
  74. def _cursor_fetch_one(cursor: Any, sql: str, params: Sequence[Any] | Mapping[str, Any] | None = None) -> dict[str, Any] | None:
  75. rows = _cursor_fetch_all(cursor, sql, params)
  76. return rows[0] if rows else None
  77. def _serialize_db_value(value: Any) -> Any:
  78. if isinstance(value, (datetime, date)):
  79. return value.isoformat()
  80. return value
  81. def _row_to_dict(row: Any) -> dict[str, Any]:
  82. if row is None:
  83. return {}
  84. mapping = getattr(row, "_mapping", row)
  85. return {key: _serialize_db_value(value) for key, value in dict(mapping).items()}
  86. def _jsonish_to_list(value: Any) -> list[Any]:
  87. if value is None:
  88. return []
  89. if isinstance(value, list):
  90. return value
  91. if isinstance(value, (tuple, set)):
  92. return list(value)
  93. if isinstance(value, bytes):
  94. value = value.decode("utf-8")
  95. if isinstance(value, str):
  96. raw = value.strip()
  97. if not raw:
  98. return []
  99. try:
  100. parsed = json.loads(raw)
  101. except json.JSONDecodeError:
  102. return [part.strip() for part in raw.split(",") if part.strip()]
  103. if isinstance(parsed, list):
  104. return parsed
  105. if parsed is None:
  106. return []
  107. return [parsed]
  108. return [value]
  109. def _to_post_id_list(value: Any) -> list[str]:
  110. post_ids: list[str] = []
  111. seen: set[str] = set()
  112. for item in _jsonish_to_list(value):
  113. post_id = str(item).strip() if item is not None else ""
  114. if post_id and post_id not in seen:
  115. seen.add(post_id)
  116. post_ids.append(post_id)
  117. return post_ids
  118. def _to_int_list(values: Any) -> list[int]:
  119. result: list[int] = []
  120. seen: set[int] = set()
  121. for value in _jsonish_to_list(values):
  122. if value is None or value == "":
  123. continue
  124. try:
  125. int_value = int(value)
  126. except (TypeError, ValueError):
  127. continue
  128. if int_value not in seen:
  129. seen.add(int_value)
  130. result.append(int_value)
  131. return result
  132. def _clean_names(names: Iterable[str]) -> list[str]:
  133. result: list[str] = []
  134. seen: set[str] = set()
  135. for raw in names:
  136. name = str(raw).strip() if raw is not None else ""
  137. if name and name not in seen:
  138. seen.add(name)
  139. result.append(name)
  140. return result
  141. def query_execution_for_evidence(execution_id: int) -> dict[str, Any] | None:
  142. """Read PG Pattern V2 execution facts for evidence validation."""
  143. row = _fetch_one(
  144. """
  145. SELECT
  146. id,
  147. snapshot_date,
  148. is_current,
  149. is_retired,
  150. status,
  151. post_filter,
  152. post_count,
  153. post_ids_digest,
  154. classify_execution_id,
  155. category_count,
  156. element_count,
  157. topic_itemset_count AS itemset_count,
  158. topic_itemset_count,
  159. topic_element_itemset_count,
  160. cross_itemset_count,
  161. script_sequence_count,
  162. paragraph_itemset_count,
  163. start_time,
  164. end_time,
  165. error_message
  166. FROM pattern_mining_execution
  167. WHERE id = %s
  168. LIMIT 1
  169. """,
  170. (int(execution_id),),
  171. )
  172. if not row:
  173. return None
  174. row["pattern_source_system"] = PG_PATTERN_SOURCE_SYSTEM
  175. return row
  176. def query_latest_success_execution_id() -> int | None:
  177. row = _fetch_one(
  178. """
  179. SELECT id
  180. FROM pattern_mining_execution
  181. WHERE status = 'success'
  182. AND COALESCE(topic_itemset_count, 0) > 0
  183. ORDER BY snapshot_date DESC NULLS LAST, id DESC
  184. LIMIT 1
  185. """
  186. )
  187. return int(row["id"]) if row else None
  188. def query_itemset_evidence(execution_id: int, itemset_ids: Iterable[Any]) -> list[dict[str, Any]]:
  189. """Read PG topic-scope itemset facts and aggregate matched posts."""
  190. clean_ids = _to_int_list(itemset_ids or [])
  191. if not clean_ids:
  192. return []
  193. rows = _fetch_all(
  194. """
  195. SELECT
  196. i.id,
  197. i.execution_id,
  198. i.mining_config_id,
  199. cfg.execution_id AS mining_config_execution_id,
  200. cfg.scope AS mining_config_scope,
  201. cfg.name AS mining_config_name,
  202. cfg.algorithm AS mining_config_algorithm,
  203. cfg.params::text AS mining_config_params,
  204. cfg.params ->> 'dimension_mode' AS mining_config_dimension_mode,
  205. COALESCE(cfg.params ->> 'target_depth', cfg.params ->> 'depth') AS mining_config_target_depth,
  206. i.scope,
  207. i.combination_type,
  208. i.item_count,
  209. i.support,
  210. i.absolute_support,
  211. i.dimensions::text AS dimensions,
  212. i.is_cross_point,
  213. i.is_cross_layer,
  214. COALESCE(
  215. array_agg(p.post_id ORDER BY p.post_id)
  216. FILTER (WHERE p.post_id IS NOT NULL),
  217. ARRAY[]::varchar[]
  218. ) AS matched_post_ids
  219. FROM pattern_itemset i
  220. JOIN pattern_mining_config cfg
  221. ON cfg.id = i.mining_config_id
  222. AND cfg.execution_id = i.execution_id
  223. AND cfg.scope = %s
  224. LEFT JOIN pattern_itemset_post p
  225. ON p.itemset_id = i.id
  226. AND p.execution_id = i.execution_id
  227. WHERE i.execution_id = %s
  228. AND i.scope = %s
  229. AND i.id = ANY(%s)
  230. GROUP BY
  231. i.id,
  232. i.execution_id,
  233. i.mining_config_id,
  234. cfg.execution_id,
  235. cfg.scope,
  236. cfg.name,
  237. cfg.algorithm,
  238. cfg.params::text,
  239. cfg.params ->> 'dimension_mode',
  240. COALESCE(cfg.params ->> 'target_depth', cfg.params ->> 'depth'),
  241. i.scope,
  242. i.combination_type,
  243. i.item_count,
  244. i.support,
  245. i.absolute_support,
  246. i.dimensions::text,
  247. i.is_cross_point,
  248. i.is_cross_layer
  249. ORDER BY i.id
  250. """,
  251. (TOPIC_SCOPE, int(execution_id), TOPIC_SCOPE, clean_ids),
  252. )
  253. result: list[dict[str, Any]] = []
  254. for row in rows:
  255. itemset = dict(row)
  256. for key in (
  257. "id",
  258. "execution_id",
  259. "mining_config_id",
  260. "mining_config_execution_id",
  261. "item_count",
  262. "absolute_support",
  263. ):
  264. if itemset.get(key) is not None:
  265. itemset[key] = int(itemset[key])
  266. itemset["support"] = float(itemset["support"]) if itemset.get("support") is not None else None
  267. itemset["is_cross_point"] = bool(itemset.get("is_cross_point"))
  268. itemset["is_cross_layer"] = bool(itemset.get("is_cross_layer"))
  269. itemset["dimensions"] = _jsonish_to_list(itemset.get("dimensions"))
  270. if isinstance(itemset.get("mining_config_params"), str):
  271. try:
  272. itemset["mining_config_params"] = json.loads(itemset["mining_config_params"])
  273. except json.JSONDecodeError:
  274. pass
  275. itemset["matched_post_ids"] = _to_post_id_list(itemset.get("matched_post_ids"))
  276. result.append(itemset)
  277. by_id = {itemset["id"]: itemset for itemset in result}
  278. return [by_id[itemset_id] for itemset_id in clean_ids if itemset_id in by_id]
  279. def query_itemset_items_with_categories(
  280. execution_id: int,
  281. itemset_ids: Iterable[Any],
  282. ) -> list[dict[str, Any]]:
  283. """Read PG itemset items and their exact category tree nodes."""
  284. clean_ids = _to_int_list(itemset_ids or [])
  285. if not clean_ids:
  286. return []
  287. rows = _fetch_all(
  288. """
  289. SELECT
  290. ii.id AS itemset_item_id,
  291. ii.itemset_id,
  292. ii.layer,
  293. ii.point_type,
  294. ii.dimension,
  295. ii.category_id,
  296. ii.category_path,
  297. ii.element_name,
  298. ii.element_id,
  299. ii.post_count,
  300. c.id AS bound_category_id,
  301. c.execution_id AS category_execution_id,
  302. c.source_stable_id AS category_source_stable_id,
  303. c.source_type AS category_source_type,
  304. c.name AS category_name,
  305. c.path AS category_full_path,
  306. c.level AS category_level,
  307. c.parent_id AS category_parent_id,
  308. c.parent_source_stable_id AS category_parent_source_stable_id,
  309. c.classified_as AS category_nature
  310. FROM pattern_itemset_item ii
  311. JOIN pattern_itemset i
  312. ON i.id = ii.itemset_id
  313. AND i.execution_id = %s
  314. AND i.scope = %s
  315. LEFT JOIN pattern_mining_category c
  316. ON c.id = ii.category_id
  317. AND c.execution_id = i.execution_id
  318. WHERE ii.itemset_id = ANY(%s)
  319. ORDER BY ii.itemset_id, ii.id
  320. """,
  321. (int(execution_id), TOPIC_SCOPE, clean_ids),
  322. )
  323. result: list[dict[str, Any]] = []
  324. for row in rows:
  325. item = dict(row)
  326. for key in (
  327. "itemset_item_id",
  328. "itemset_id",
  329. "category_id",
  330. "element_id",
  331. "post_count",
  332. "bound_category_id",
  333. "category_execution_id",
  334. "category_source_stable_id",
  335. "category_level",
  336. "category_parent_id",
  337. "category_parent_source_stable_id",
  338. ):
  339. if item.get(key) is not None:
  340. item[key] = int(item[key])
  341. item["category_found"] = item.get("bound_category_id") is not None
  342. result.append(item)
  343. return result
  344. def query_element_bindings_for_items(
  345. execution_id: int,
  346. itemset_items: Sequence[Mapping[str, Any]],
  347. post_ids: Iterable[Any] | None = None,
  348. limit_per_item: int = 200,
  349. ) -> list[dict[str, Any]]:
  350. """Bind itemset items to real PG Pattern V2 source-post elements."""
  351. clean_post_ids = _to_post_id_list(post_ids)
  352. if not itemset_items:
  353. return []
  354. bindings: list[dict[str, Any]] = []
  355. with _connect_readonly() as conn:
  356. with conn.cursor(cursor_factory=RealDictCursor) as cursor:
  357. for raw_item in itemset_items:
  358. item = dict(raw_item)
  359. category_id = item.get("bound_category_id") or item.get("category_id")
  360. if category_id is None:
  361. continue
  362. clauses = [
  363. "execution_id = %s",
  364. "category_id = %s",
  365. "source_table = %s",
  366. ]
  367. params: list[Any] = [int(execution_id), int(category_id), TOPIC_ELEMENT_SOURCE_TABLE]
  368. if item.get("point_type"):
  369. clauses.append("point_type = %s")
  370. params.append(item["point_type"])
  371. if item.get("dimension"):
  372. clauses.append("element_type = %s")
  373. params.append(item["dimension"])
  374. if item.get("element_name"):
  375. clauses.append("name = %s")
  376. params.append(item["element_name"])
  377. if clean_post_ids:
  378. clauses.append("post_id = ANY(%s)")
  379. params.append(clean_post_ids)
  380. where_sql = " AND ".join(clauses)
  381. count_row = _cursor_fetch_one(
  382. cursor,
  383. f"""
  384. SELECT
  385. COUNT(*) AS matched_element_count,
  386. COUNT(DISTINCT post_id) AS matched_post_count
  387. FROM pattern_mining_element
  388. WHERE {where_sql}
  389. """,
  390. tuple(params),
  391. ) or {}
  392. post_rows = _cursor_fetch_all(
  393. cursor,
  394. f"""
  395. SELECT DISTINCT post_id
  396. FROM pattern_mining_element
  397. WHERE {where_sql}
  398. ORDER BY post_id
  399. LIMIT %s
  400. """,
  401. tuple(params + [int(limit_per_item)]),
  402. )
  403. sample_rows = _cursor_fetch_all(
  404. cursor,
  405. f"""
  406. SELECT
  407. id,
  408. post_id,
  409. source_table,
  410. source_element_id,
  411. point_type,
  412. point_text,
  413. element_type,
  414. name,
  415. category_id,
  416. category_path,
  417. topic_point_id
  418. FROM pattern_mining_element
  419. WHERE {where_sql}
  420. ORDER BY post_id, id
  421. LIMIT %s
  422. """,
  423. tuple(params + [min(int(limit_per_item), 20)]),
  424. )
  425. bindings.append(
  426. {
  427. "itemset_id": item.get("itemset_id"),
  428. "itemset_item_id": item.get("itemset_item_id"),
  429. "category_id": int(category_id),
  430. "point_type": item.get("point_type"),
  431. "dimension": item.get("dimension"),
  432. "element_name": item.get("element_name"),
  433. "matched_element_count": int(count_row.get("matched_element_count") or 0),
  434. "matched_post_count": int(count_row.get("matched_post_count") or 0),
  435. "matched_post_ids": _to_post_id_list([row["post_id"] for row in post_rows]),
  436. "sample_elements": sample_rows,
  437. }
  438. )
  439. return bindings
  440. def query_video_ids_by_names(execution_id: int, names: Iterable[str]) -> list[str]:
  441. """Resolve names to PG source post IDs by category name/path leaf or element name."""
  442. clean_names = _clean_names(names)
  443. if not clean_names:
  444. return []
  445. rows = _fetch_all(
  446. """
  447. WITH target_categories AS (
  448. SELECT id
  449. FROM pattern_mining_category
  450. WHERE execution_id = %s
  451. AND (
  452. name = ANY(%s)
  453. OR regexp_replace(path, '^.*/', '') = ANY(%s)
  454. )
  455. ),
  456. matched_elements AS (
  457. SELECT post_id
  458. FROM pattern_mining_element
  459. WHERE execution_id = %s
  460. AND source_table = %s
  461. AND (
  462. name = ANY(%s)
  463. OR category_id IN (SELECT id FROM target_categories)
  464. )
  465. )
  466. SELECT DISTINCT post_id
  467. FROM matched_elements
  468. WHERE post_id IS NOT NULL
  469. ORDER BY post_id
  470. """,
  471. (int(execution_id), clean_names, clean_names, int(execution_id), TOPIC_ELEMENT_SOURCE_TABLE, clean_names),
  472. )
  473. return _to_post_id_list([row["post_id"] for row in rows])
  474. def query_category_level(execution_id: int, name: str) -> int | None:
  475. clean_name = str(name).strip() if name is not None else ""
  476. if not clean_name:
  477. return None
  478. row = _fetch_one(
  479. """
  480. SELECT level
  481. FROM pattern_mining_category
  482. WHERE execution_id = %s
  483. AND (name = %s OR regexp_replace(path, '^.*/', '') = %s)
  484. ORDER BY id DESC
  485. LIMIT 1
  486. """,
  487. (int(execution_id), clean_name, clean_name),
  488. )
  489. return int(row["level"]) if row and row.get("level") is not None else None
  490. def query_case_ids_by_post_ids(post_ids: Iterable[Any]) -> list[dict[str, Any]]:
  491. """PG Pattern V2 has post_id semantics for MVP exact evidence.
  492. DemandAgent no longer reads the old MySQL decode-case table in this PG-only
  493. path. `case_ids` is filled from validated post IDs by the evidence builder;
  494. `decode_case_ids` remains an optional empty compatibility field.
  495. """
  496. return []
  497. def query_categories(
  498. execution_id: int,
  499. *,
  500. source_type: str | None = None,
  501. keyword: str | None = None,
  502. category_ids: Iterable[Any] | None = None,
  503. limit: int | None = None,
  504. ) -> list[dict[str, Any]]:
  505. clauses = ["execution_id = %s"]
  506. params: list[Any] = [int(execution_id)]
  507. if source_type:
  508. clauses.append("source_type = %s")
  509. params.append(source_type)
  510. if keyword:
  511. clauses.append("(name ILIKE %s OR path ILIKE %s)")
  512. like = f"%{keyword}%"
  513. params.extend([like, like])
  514. clean_ids = _to_int_list(category_ids or [])
  515. if clean_ids:
  516. clauses.append("id = ANY(%s)")
  517. params.append(clean_ids)
  518. limit_sql = "LIMIT %s" if limit else ""
  519. if limit:
  520. params.append(int(limit))
  521. return _fetch_all(
  522. f"""
  523. SELECT
  524. id,
  525. execution_id,
  526. source_stable_id,
  527. source_type,
  528. name,
  529. description,
  530. path,
  531. level,
  532. parent_id,
  533. parent_source_stable_id,
  534. element_count,
  535. classified_as AS category_nature
  536. FROM pattern_mining_category
  537. WHERE {" AND ".join(clauses)}
  538. ORDER BY source_type, path, id
  539. {limit_sql}
  540. """,
  541. tuple(params),
  542. )
  543. def query_elements(
  544. execution_id: int,
  545. *,
  546. keyword: str | None = None,
  547. element_type: str | None = None,
  548. post_ids: Iterable[Any] | None = None,
  549. category_ids: Iterable[Any] | None = None,
  550. limit: int | None = None,
  551. ) -> list[dict[str, Any]]:
  552. clauses = [
  553. "execution_id = %s",
  554. "source_table = %s",
  555. ]
  556. params: list[Any] = [int(execution_id), TOPIC_ELEMENT_SOURCE_TABLE]
  557. if keyword:
  558. clauses.append("name ILIKE %s")
  559. params.append(f"%{keyword}%")
  560. if element_type:
  561. clauses.append("element_type = %s")
  562. params.append(element_type)
  563. clean_posts = _to_post_id_list(post_ids)
  564. if clean_posts:
  565. clauses.append("post_id = ANY(%s)")
  566. params.append(clean_posts)
  567. clean_categories = _to_int_list(category_ids or [])
  568. if clean_categories:
  569. clauses.append("category_id = ANY(%s)")
  570. params.append(clean_categories)
  571. limit_sql = "LIMIT %s" if limit else ""
  572. if limit:
  573. params.append(int(limit))
  574. return _fetch_all(
  575. f"""
  576. SELECT
  577. id,
  578. execution_id,
  579. post_id,
  580. source_table,
  581. source_element_id,
  582. element_type,
  583. element_sub_type,
  584. name,
  585. description,
  586. category_id,
  587. category_path,
  588. point_type,
  589. point_text,
  590. topic_point_id
  591. FROM pattern_mining_element
  592. WHERE {" AND ".join(clauses)}
  593. ORDER BY post_id, id
  594. {limit_sql}
  595. """,
  596. tuple(params),
  597. )
  598. def query_topic_itemsets(
  599. execution_id: int,
  600. *,
  601. category_ids: Iterable[Any] | None = None,
  602. dimension_mode: str | None = None,
  603. min_support: int | None = None,
  604. min_item_count: int | None = None,
  605. max_item_count: int | None = None,
  606. sort_by: str = "absolute_support",
  607. limit: int = 100,
  608. ) -> list[dict[str, Any]]:
  609. clauses = [
  610. "i.execution_id = %s",
  611. "i.scope = %s",
  612. ]
  613. params: list[Any] = [int(execution_id), TOPIC_SCOPE]
  614. clean_category_ids = _to_int_list(category_ids or [])
  615. for category_id in clean_category_ids:
  616. clauses.append(
  617. """
  618. EXISTS (
  619. SELECT 1
  620. FROM pattern_itemset_item ii_filter
  621. WHERE ii_filter.itemset_id = i.id
  622. AND ii_filter.category_id = %s
  623. )
  624. """
  625. )
  626. params.append(category_id)
  627. if dimension_mode:
  628. clauses.append("cfg.params ->> 'dimension_mode' = %s")
  629. params.append(dimension_mode)
  630. if min_support is not None:
  631. clauses.append("i.absolute_support >= %s")
  632. params.append(int(min_support))
  633. if min_item_count is not None:
  634. clauses.append("i.item_count >= %s")
  635. params.append(int(min_item_count))
  636. if max_item_count is not None:
  637. clauses.append("i.item_count <= %s")
  638. params.append(int(max_item_count))
  639. order_sql = {
  640. "support": "i.support DESC NULLS LAST, i.absolute_support DESC NULLS LAST",
  641. "item_count": "i.item_count DESC NULLS LAST, i.absolute_support DESC NULLS LAST",
  642. "absolute_support": "i.absolute_support DESC NULLS LAST, i.support DESC NULLS LAST",
  643. }.get(str(sort_by or "absolute_support"), "i.absolute_support DESC NULLS LAST, i.support DESC NULLS LAST")
  644. params.append(int(limit))
  645. return _fetch_all(
  646. f"""
  647. SELECT
  648. i.id,
  649. i.execution_id,
  650. i.mining_config_id,
  651. cfg.scope AS mining_config_scope,
  652. cfg.name AS mining_config_name,
  653. cfg.params AS mining_config_params,
  654. cfg.params ->> 'dimension_mode' AS dimension_mode,
  655. COALESCE(cfg.params ->> 'target_depth', cfg.params ->> 'depth') AS target_depth,
  656. i.scope,
  657. i.combination_type,
  658. i.item_count,
  659. i.support,
  660. i.absolute_support,
  661. i.dimensions,
  662. i.is_cross_point,
  663. i.is_cross_layer
  664. FROM pattern_itemset i
  665. JOIN pattern_mining_config cfg
  666. ON cfg.id = i.mining_config_id
  667. AND cfg.execution_id = i.execution_id
  668. AND cfg.scope = %s
  669. WHERE {" AND ".join(clauses)}
  670. ORDER BY {order_sql}, i.id
  671. LIMIT %s
  672. """,
  673. tuple([TOPIC_SCOPE, *params]),
  674. )
  675. def query_weight_score_rows(
  676. execution_id: int,
  677. *,
  678. level: str,
  679. dimension: str,
  680. limit: int = 5000,
  681. ) -> list[dict[str, Any]]:
  682. """Aggregate PG topic elements into old weight-score JSON shapes."""
  683. if level == "元素":
  684. return _fetch_all(
  685. """
  686. SELECT
  687. name,
  688. element_type,
  689. category_id,
  690. category_path,
  691. COUNT(*) AS occurrence_count,
  692. COUNT(DISTINCT post_id) AS post_count
  693. FROM pattern_mining_element
  694. WHERE execution_id = %s
  695. AND source_table = %s
  696. AND element_type = %s
  697. AND name IS NOT NULL
  698. AND name <> ''
  699. GROUP BY name, element_type, category_id, category_path
  700. ORDER BY COUNT(DISTINCT post_id) DESC, COUNT(*) DESC, name
  701. LIMIT %s
  702. """,
  703. (int(execution_id), TOPIC_ELEMENT_SOURCE_TABLE, dimension, int(limit)),
  704. )
  705. if level == "分类":
  706. return _fetch_all(
  707. """
  708. SELECT
  709. COALESCE(c.name, regexp_replace(e.category_path, '^.*/', '')) AS category,
  710. e.category_id,
  711. e.category_path,
  712. e.element_type,
  713. COUNT(*) AS occurrence_count,
  714. COUNT(DISTINCT e.post_id) AS post_count,
  715. COALESCE(MAX(c.level), array_length(string_to_array(trim(both '/' from e.category_path), '/'), 1)) AS level
  716. FROM pattern_mining_element e
  717. LEFT JOIN pattern_mining_category c
  718. ON c.id = e.category_id
  719. AND c.execution_id = e.execution_id
  720. WHERE e.execution_id = %s
  721. AND e.source_table = %s
  722. AND e.element_type = %s
  723. AND e.category_id IS NOT NULL
  724. AND e.category_path IS NOT NULL
  725. GROUP BY e.category_id, e.category_path, e.element_type, c.name
  726. ORDER BY COUNT(DISTINCT e.post_id) DESC, COUNT(*) DESC, e.category_path
  727. LIMIT %s
  728. """,
  729. (int(execution_id), TOPIC_ELEMENT_SOURCE_TABLE, dimension, int(limit)),
  730. )
  731. raise ValueError(f"unsupported weight score level: {level}")