pg_pattern_repository.py 37 KB

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