pg_pattern_service.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. """PG Pattern V2 adapter for DemandAgent pattern tools.
  2. The public function names mirror the old MySQL pattern service, but every query
  3. reads PG `open_aigc.public` Pattern V2 facts and only exposes topic-scope
  4. itemsets as exact evidence candidates.
  5. """
  6. from __future__ import annotations
  7. from collections import defaultdict
  8. from typing import Any, Iterable
  9. from examples.demand import pg_pattern_repository as repo
  10. def _clean_ints(values: Iterable[Any] | None) -> list[int]:
  11. result: list[int] = []
  12. seen: set[int] = set()
  13. for raw in values or []:
  14. try:
  15. value = int(raw)
  16. except (TypeError, ValueError):
  17. continue
  18. if value not in seen:
  19. seen.add(value)
  20. result.append(value)
  21. return result
  22. def _last_path_part(path: Any) -> str:
  23. text = str(path or "").strip()
  24. if not text:
  25. return ""
  26. return text.replace(">", "/").split("/")[-1].strip()
  27. def _group_point_types(rows: list[dict[str, Any]]) -> list[str]:
  28. result: list[str] = []
  29. seen: set[str] = set()
  30. for row in rows:
  31. value = str(row.get("point_type") or "").strip()
  32. if value and value not in seen:
  33. seen.add(value)
  34. result.append(value)
  35. return result
  36. def _format_item(item: dict[str, Any]) -> dict[str, Any]:
  37. return {
  38. "id": item.get("itemset_item_id"),
  39. "itemset_id": item.get("itemset_id"),
  40. "layer": item.get("layer"),
  41. "point_type": item.get("point_type"),
  42. "dimension": item.get("dimension"),
  43. "category_id": item.get("category_id"),
  44. "category_path": item.get("category_path") or item.get("category_full_path"),
  45. "category_name": item.get("category_name") or _last_path_part(item.get("category_path")),
  46. "element_name": item.get("element_name"),
  47. "post_count": item.get("post_count"),
  48. }
  49. def get_category_tree_compact(execution_id: int, source_type: str | None = None) -> str:
  50. categories = repo.query_categories(execution_id, source_type=source_type)
  51. if not categories:
  52. return f"未找到 PG Pattern V2 分类树,execution_id={execution_id}, source_type={source_type}"
  53. lines = [
  54. f"PG Pattern V2 分类树快照 execution_id={execution_id}",
  55. "说明:本 DemandAgent MVP 只使用 scope=topic 的分类 Pattern;topic_element 不进入最终证据。",
  56. ]
  57. for cat in categories:
  58. level = int(cat.get("level") or 0)
  59. indent = " " * max(level - 1, 0)
  60. lines.append(
  61. f"{indent}- [{cat.get('id')}] {cat.get('name')} "
  62. f"(type={cat.get('source_type')}, level={level}, elements={cat.get('element_count') or 0}, "
  63. f"path={cat.get('path')})"
  64. )
  65. return "\n".join(lines)
  66. def search_top_itemsets(
  67. execution_id: int,
  68. top_n: int = 20,
  69. category_ids: list | None = None,
  70. dimension_mode: str | None = None,
  71. min_support: int | None = None,
  72. min_item_count: int | None = None,
  73. max_item_count: int | None = None,
  74. sort_by: str = "absolute_support",
  75. account_name=None,
  76. merge_leve2=None,
  77. ) -> dict[str, Any]:
  78. del account_name, merge_leve2 # PG Pattern V2 MVP does not filter itemsets by post metadata here.
  79. rows = repo.query_topic_itemsets(
  80. execution_id=execution_id,
  81. category_ids=category_ids,
  82. dimension_mode=dimension_mode,
  83. min_support=min_support,
  84. min_item_count=min_item_count,
  85. max_item_count=max_item_count,
  86. sort_by=sort_by,
  87. limit=max(int(top_n or 20) * 10, int(top_n or 20)),
  88. )
  89. itemset_ids = [int(row["id"]) for row in rows]
  90. items_by_itemset: dict[int, list[dict[str, Any]]] = defaultdict(list)
  91. if itemset_ids:
  92. for item in repo.query_itemset_items_with_categories(execution_id, itemset_ids):
  93. items_by_itemset[int(item["itemset_id"])].append(_format_item(item))
  94. groups: dict[str, dict[str, Any]] = {}
  95. for row in rows:
  96. dimension_key = row.get("dimension_mode") or row.get("mining_config_scope") or "topic"
  97. target_depth = row.get("target_depth") or ""
  98. key = f"{dimension_key}/{target_depth}"
  99. group = groups.setdefault(
  100. key,
  101. {
  102. "dimension_mode": dimension_key,
  103. "target_depth": target_depth,
  104. "total": 0,
  105. "itemsets": [],
  106. },
  107. )
  108. if len(group["itemsets"]) >= int(top_n or 20):
  109. continue
  110. group["total"] += 1
  111. group["itemsets"].append(
  112. {
  113. "id": row.get("id"),
  114. "scope": row.get("scope"),
  115. "mining_config_id": row.get("mining_config_id"),
  116. "dimension_mode": dimension_key,
  117. "target_depth": target_depth,
  118. "combination_type": row.get("combination_type"),
  119. "item_count": row.get("item_count"),
  120. "support": row.get("support"),
  121. "absolute_support": row.get("absolute_support"),
  122. "dimensions": row.get("dimensions") or [],
  123. "items": items_by_itemset.get(int(row["id"]), []),
  124. }
  125. )
  126. return {
  127. "pattern_source_system": repo.PG_PATTERN_SOURCE_SYSTEM,
  128. "scope": repo.TOPIC_SCOPE,
  129. "total": len(rows),
  130. "showing": sum(len(group["itemsets"]) for group in groups.values()),
  131. "groups": groups,
  132. }
  133. def get_itemset_posts(itemset_ids: list[int], execution_id: int | None = None) -> list[dict[str, Any]]:
  134. clean_ids = _clean_ints(itemset_ids)
  135. if not clean_ids:
  136. return []
  137. if execution_id is None:
  138. raise ValueError("PG Pattern V2 get_itemset_posts requires execution_id")
  139. itemsets = repo.query_itemset_evidence(execution_id=execution_id, itemset_ids=clean_ids)
  140. itemset_items = repo.query_itemset_items_with_categories(execution_id=execution_id, itemset_ids=clean_ids)
  141. items_by_itemset: dict[int, list[dict[str, Any]]] = defaultdict(list)
  142. for item in itemset_items:
  143. items_by_itemset[int(item["itemset_id"])].append(_format_item(item))
  144. details: list[dict[str, Any]] = []
  145. for itemset in itemsets:
  146. details.append(
  147. {
  148. "id": itemset.get("id"),
  149. "execution_id": itemset.get("execution_id"),
  150. "scope": itemset.get("scope"),
  151. "mining_config_id": itemset.get("mining_config_id"),
  152. "dimension_mode": itemset.get("mining_config_dimension_mode"),
  153. "target_depth": itemset.get("mining_config_target_depth"),
  154. "combination_type": itemset.get("combination_type"),
  155. "item_count": itemset.get("item_count"),
  156. "support": itemset.get("support"),
  157. "absolute_support": itemset.get("absolute_support"),
  158. "dimensions": itemset.get("dimensions") or [],
  159. "items": items_by_itemset.get(int(itemset["id"]), []),
  160. "post_ids": itemset.get("matched_post_ids") or [],
  161. "matched_post_ids": itemset.get("matched_post_ids") or [],
  162. }
  163. )
  164. return details
  165. def get_post_elements(execution_id: int, post_ids: list) -> dict[str, Any]:
  166. rows = repo.query_elements(execution_id, post_ids=post_ids, limit=5000)
  167. result: dict[str, Any] = {}
  168. grouped: dict[tuple[str, str, str], dict[str, Any]] = {}
  169. for row in rows:
  170. post_id = str(row.get("post_id") or "")
  171. point_type = str(row.get("point_type") or "未分点")
  172. point_text = str(row.get("point_text") or "")
  173. key = (post_id, point_type, point_text)
  174. point = grouped.setdefault(
  175. key,
  176. {
  177. "point_text": point_text,
  178. "elements": {"实质": [], "形式": [], "意图": []},
  179. },
  180. )
  181. element_type = str(row.get("element_type") or "其他")
  182. point["elements"].setdefault(element_type, []).append(
  183. {
  184. "id": row.get("id"),
  185. "source_element_id": row.get("source_element_id"),
  186. "name": row.get("name"),
  187. "description": row.get("description"),
  188. "category_id": row.get("category_id"),
  189. "category_path": row.get("category_path"),
  190. }
  191. )
  192. for (post_id, point_type, _), point in grouped.items():
  193. result.setdefault(post_id, {}).setdefault(point_type, []).append(point)
  194. return result
  195. def search_elements(
  196. execution_id: int,
  197. keyword: str,
  198. element_type: str | None = None,
  199. limit: int = 50,
  200. account_name=None,
  201. merge_leve2=None,
  202. ) -> list[dict[str, Any]]:
  203. del account_name, merge_leve2
  204. rows = repo.query_elements(execution_id, keyword=keyword, element_type=element_type, limit=5000)
  205. grouped: dict[tuple[str, str, int | None, str | None], list[dict[str, Any]]] = defaultdict(list)
  206. for row in rows:
  207. key = (
  208. str(row.get("name") or ""),
  209. str(row.get("element_type") or ""),
  210. row.get("category_id"),
  211. row.get("category_path"),
  212. )
  213. grouped[key].append(row)
  214. items: list[dict[str, Any]] = []
  215. for (name, etype, category_id, category_path), group_rows in grouped.items():
  216. if not name:
  217. continue
  218. posts = {str(row.get("post_id")) for row in group_rows if row.get("post_id") is not None}
  219. items.append(
  220. {
  221. "name": name,
  222. "element_type": etype,
  223. "category_id": category_id,
  224. "category_path": category_path,
  225. "point_types": _group_point_types(group_rows),
  226. "occurrence_count": len(group_rows),
  227. "post_count": len(posts),
  228. }
  229. )
  230. items.sort(key=lambda item: (item["post_count"], item["occurrence_count"]), reverse=True)
  231. return items[: int(limit or 50)]
  232. def get_element_category_chain(
  233. execution_id: int,
  234. element_name: str,
  235. element_type: str | None = None,
  236. ) -> list[dict[str, Any]]:
  237. rows = repo.query_elements(execution_id, keyword=element_name, element_type=element_type, limit=2000)
  238. exact_rows = [row for row in rows if str(row.get("name") or "") == str(element_name)]
  239. category_ids = _clean_ints(row.get("category_id") for row in exact_rows if row.get("category_id"))
  240. categories = {int(row["id"]): row for row in repo.query_categories(execution_id, category_ids=category_ids)}
  241. results: list[dict[str, Any]] = []
  242. seen: set[int] = set()
  243. for row in exact_rows:
  244. category_id = row.get("category_id")
  245. if category_id is None or int(category_id) in seen:
  246. continue
  247. seen.add(int(category_id))
  248. category = categories.get(int(category_id), {})
  249. results.append(
  250. {
  251. "category_id": category_id,
  252. "category_path": row.get("category_path") or category.get("path"),
  253. "element_type": row.get("element_type"),
  254. "point_types": _group_point_types([r for r in exact_rows if r.get("category_id") == category_id]),
  255. "ancestors": _ancestors_from_path(category.get("path") or row.get("category_path")),
  256. }
  257. )
  258. return results
  259. def _ancestors_from_path(path: Any) -> list[dict[str, Any]]:
  260. parts = [part.strip() for part in str(path or "").replace(">", "/").split("/") if part.strip()]
  261. return [{"name": part, "level": index + 1, "path": "/".join(parts[: index + 1])} for index, part in enumerate(parts)]
  262. def get_category_detail_with_context(execution_id: int, category_id: int) -> dict[str, Any] | None:
  263. categories = repo.query_categories(execution_id, category_ids=[category_id])
  264. if not categories:
  265. return None
  266. category = categories[0]
  267. all_categories = repo.query_categories(execution_id)
  268. children = [cat for cat in all_categories if cat.get("parent_id") == int(category_id)]
  269. siblings = [
  270. cat for cat in all_categories
  271. if cat.get("parent_id") == category.get("parent_id") and cat.get("id") != category.get("id")
  272. ][:50]
  273. elements = get_category_elements(category_id, execution_id=execution_id)
  274. return {
  275. "category": category,
  276. "ancestors": _ancestors_from_path(category.get("path")),
  277. "children": children[:100],
  278. "siblings": siblings,
  279. "elements": elements[:100],
  280. }
  281. def search_categories(execution_id: int, keyword: str, source_type: str | None = None) -> list[dict[str, Any]]:
  282. categories = repo.query_categories(execution_id, source_type=source_type, keyword=keyword, limit=100)
  283. if not categories:
  284. return []
  285. category_ids = _clean_ints(cat.get("id") for cat in categories)
  286. element_rows = repo.query_elements(execution_id, category_ids=category_ids, limit=5000)
  287. elements_by_category: dict[int, list[dict[str, Any]]] = defaultdict(list)
  288. for row in element_rows:
  289. if row.get("category_id") is not None:
  290. elements_by_category[int(row["category_id"])].append(row)
  291. result: list[dict[str, Any]] = []
  292. for cat in categories:
  293. rows = elements_by_category.get(int(cat["id"]), [])
  294. result.append(
  295. {
  296. **cat,
  297. "point_types": _group_point_types(rows),
  298. "post_count": len({str(row.get("post_id")) for row in rows if row.get("post_id") is not None}),
  299. }
  300. )
  301. return result
  302. def get_category_elements(
  303. category_id: int,
  304. execution_id: int,
  305. account_name=None,
  306. merge_leve2=None,
  307. ) -> list[dict[str, Any]]:
  308. del account_name, merge_leve2
  309. rows = repo.query_elements(execution_id, category_ids=[category_id], limit=5000)
  310. grouped: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
  311. for row in rows:
  312. name = str(row.get("name") or "").strip()
  313. element_type = str(row.get("element_type") or "").strip()
  314. if name:
  315. grouped[(name, element_type)].append(row)
  316. result: list[dict[str, Any]] = []
  317. for (name, element_type), group_rows in grouped.items():
  318. result.append(
  319. {
  320. "name": name,
  321. "element_type": element_type,
  322. "point_types": _group_point_types(group_rows),
  323. "occurrence_count": len(group_rows),
  324. "post_count": len({str(row.get("post_id")) for row in group_rows if row.get("post_id") is not None}),
  325. }
  326. )
  327. result.sort(key=lambda item: (item["post_count"], item["occurrence_count"]), reverse=True)
  328. return result
  329. def get_category_co_occurrences(
  330. execution_id: int,
  331. category_ids: list,
  332. top_n: int = 30,
  333. account_name=None,
  334. merge_leve2=None,
  335. ) -> dict[str, Any]:
  336. del account_name, merge_leve2
  337. clean_ids = _clean_ints(category_ids)
  338. if not clean_ids:
  339. return {"matched_post_count": 0, "co_categories": []}
  340. rows = repo.query_elements(execution_id, category_ids=clean_ids, limit=10000)
  341. posts_by_category: dict[int, set[str]] = defaultdict(set)
  342. for row in rows:
  343. if row.get("category_id") is not None and row.get("post_id") is not None:
  344. posts_by_category[int(row["category_id"])].add(str(row["post_id"]))
  345. matched_posts: set[str] | None = None
  346. for category_id in clean_ids:
  347. current = posts_by_category.get(category_id, set())
  348. matched_posts = current if matched_posts is None else matched_posts & current
  349. matched_posts = matched_posts or set()
  350. co_rows = repo.query_elements(execution_id, post_ids=list(matched_posts)[:5000], limit=20000)
  351. counts: dict[tuple[int, str, str], set[str]] = defaultdict(set)
  352. for row in co_rows:
  353. cat_id = row.get("category_id")
  354. if cat_id is None or int(cat_id) in clean_ids:
  355. continue
  356. counts[(int(cat_id), str(row.get("category_path") or ""), str(row.get("element_type") or ""))].add(
  357. str(row.get("post_id"))
  358. )
  359. ranked = sorted(counts.items(), key=lambda item: len(item[1]), reverse=True)
  360. return {
  361. "matched_post_count": len(matched_posts),
  362. "matched_post_ids": sorted(matched_posts)[:100],
  363. "co_categories": [
  364. {
  365. "category_id": key[0],
  366. "category_path": key[1],
  367. "element_type": key[2],
  368. "post_count": len(posts),
  369. }
  370. for key, posts in ranked[: int(top_n or 30)]
  371. ],
  372. }
  373. def get_element_co_occurrences(
  374. execution_id: int,
  375. element_names: list,
  376. top_n: int = 30,
  377. account_name=None,
  378. merge_leve2=None,
  379. ) -> dict[str, Any]:
  380. del account_name, merge_leve2
  381. names = [str(name).strip() for name in element_names or [] if str(name).strip()]
  382. if not names:
  383. return {"matched_post_count": 0, "co_elements": []}
  384. posts_by_name: dict[str, set[str]] = {}
  385. for name in names:
  386. rows = repo.query_elements(execution_id, keyword=name, limit=10000)
  387. posts_by_name[name] = {
  388. str(row.get("post_id"))
  389. for row in rows
  390. if str(row.get("name") or "") == name and row.get("post_id") is not None
  391. }
  392. matched_posts: set[str] | None = None
  393. for posts in posts_by_name.values():
  394. matched_posts = posts if matched_posts is None else matched_posts & posts
  395. matched_posts = matched_posts or set()
  396. co_rows = repo.query_elements(execution_id, post_ids=list(matched_posts)[:5000], limit=20000)
  397. grouped: dict[tuple[str, str, int | None, str | None], list[dict[str, Any]]] = defaultdict(list)
  398. for row in co_rows:
  399. name = str(row.get("name") or "")
  400. if not name or name in names:
  401. continue
  402. grouped[(name, str(row.get("element_type") or ""), row.get("category_id"), row.get("category_path"))].append(row)
  403. ranked = sorted(grouped.items(), key=lambda item: len({r.get("post_id") for r in item[1]}), reverse=True)
  404. return {
  405. "matched_post_count": len(matched_posts),
  406. "matched_post_ids": sorted(matched_posts)[:100],
  407. "co_elements": [
  408. {
  409. "name": key[0],
  410. "element_type": key[1],
  411. "category_id": key[2],
  412. "category_path": key[3],
  413. "point_types": _group_point_types(rows),
  414. "occurrence_count": len(rows),
  415. "post_count": len({str(row.get("post_id")) for row in rows if row.get("post_id") is not None}),
  416. }
  417. for key, rows in ranked[: int(top_n or 30)]
  418. ],
  419. }