video_feature_query.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. """落地页视频素材召回使用的 ODPS 视频特征查询。
  2. videoContentList 只负责选择落地页视频;素材召回特征按视频 ID 从
  3. loghubods.dwd_video_element_contribution_analysis 读取。
  4. """
  5. from __future__ import annotations
  6. import logging
  7. from dataclasses import dataclass
  8. from pathlib import Path
  9. from typing import Iterable
  10. logger = logging.getLogger(__name__)
  11. _MINI_DIR = Path(__file__).resolve().parent.parent
  12. @dataclass(frozen=True)
  13. class VideoElementFeature:
  14. video_id: int
  15. element_dimension: str
  16. point_type: str
  17. standard_element: str
  18. contribution_score: float
  19. dt: str
  20. CREATE_CACHE_TABLE_SQL = """
  21. CREATE TABLE IF NOT EXISTS video_element_feature_cache (
  22. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  23. video_id BIGINT NOT NULL COMMENT '业务视频ID,对应 ODPS vid',
  24. dt VARCHAR(8) NOT NULL COMMENT 'ODPS 分区日期 YYYYMMDD',
  25. point_type VARCHAR(50) NOT NULL COMMENT '点类型',
  26. standard_element VARCHAR(1024) NOT NULL COMMENT '标准化元素或解构选题文本',
  27. element_dimension VARCHAR(50) NOT NULL DEFAULT '实质' COMMENT '元素维度',
  28. contribution_score DOUBLE NOT NULL DEFAULT 0 COMMENT '贡献分',
  29. is_miss BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否为无特征负缓存',
  30. raw_element VARCHAR(255) DEFAULT NULL COMMENT '原始元素名称',
  31. element_id VARCHAR(100) DEFAULT NULL COMMENT '标准化元素ID',
  32. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  33. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  34. UNIQUE KEY uk_dt_video_point_element (dt, video_id, element_dimension, point_type, standard_element(191)),
  35. KEY idx_video_dt (video_id, dt),
  36. KEY idx_dt (dt),
  37. KEY idx_updated_at (updated_at)
  38. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频元素特征缓存'
  39. """
  40. def _quote_sql_string(value: str) -> str:
  41. """把字符串转义并加单引号,用于拼 ODPS SQL 字面量。"""
  42. return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
  43. def _chunks(items: list[int], size: int) -> Iterable[list[int]]:
  44. """把列表按 size 切成批次,逐批 yield。"""
  45. for i in range(0, len(items), size):
  46. yield items[i:i + size]
  47. def _get_odps_client():
  48. """获取 loghubods 项目的 ODPS client(动态注入 tools 目录到 sys.path)。"""
  49. import sys
  50. tools_dir = _MINI_DIR / "tools"
  51. if str(tools_dir) not in sys.path:
  52. sys.path.insert(0, str(tools_dir))
  53. from odps_module import get_odps_client
  54. return get_odps_client(project="loghubods")
  55. def _latest_partition_dt(client) -> str:
  56. """获取 dwd_video_element_contribution_analysis 表的最新分区 dt(YYYYMMDD)。
  57. 先走表元数据 get_max_partition,失败时降级用 MAX_PT 查询。
  58. """
  59. table_name = "dwd_video_element_contribution_analysis"
  60. try:
  61. table = client._odps.get_table(table_name)
  62. partition = table.get_max_partition()
  63. if partition is not None:
  64. name = getattr(partition, "name", "") or str(partition)
  65. for part in name.split(","):
  66. if part.startswith("dt="):
  67. return part.split("=", 1)[1].strip("'\"")
  68. except Exception as e:
  69. logger.warning("[video_feature] get max partition by metadata failed:%s", e)
  70. rows = client.query(
  71. "SELECT MAX_PT('loghubods.dwd_video_element_contribution_analysis') AS dt",
  72. limit=1,
  73. )
  74. if rows and rows[0].get("dt"):
  75. return str(rows[0]["dt"])
  76. raise RuntimeError("无法获取 dwd_video_element_contribution_analysis 最新分区")
  77. def _query_odps_rows(client, sql: str, columns: list[str]) -> list[dict]:
  78. """条件允许时不经 tunnel 读取 ODPS 行。
  79. 部分运行环境无法访问配置的 tunnel endpoint;小批量特征查询使用实例读取器
  80. 更可靠。
  81. """
  82. odps = getattr(client, "_odps", None)
  83. if odps is None:
  84. return client.query(sql)
  85. instance = odps.execute_sql(sql)
  86. instance.wait_for_success()
  87. rows: list[dict] = []
  88. with instance.open_reader(tunnel=False) as reader:
  89. for record in reader:
  90. rows.append({col: record[i] for i, col in enumerate(columns)})
  91. return rows
  92. def _ensure_cache_table() -> None:
  93. """确保 MySQL 缓存表 video_element_feature_cache 存在且结构、索引符合预期(幂等迁移)。"""
  94. from db.connection import get_connection
  95. conn = get_connection()
  96. try:
  97. with conn.cursor() as cur:
  98. cur.execute(CREATE_CACHE_TABLE_SQL)
  99. cur.execute("SHOW COLUMNS FROM video_element_feature_cache LIKE 'element_dimension'")
  100. if not cur.fetchone():
  101. cur.execute(
  102. """
  103. ALTER TABLE video_element_feature_cache
  104. ADD COLUMN element_dimension VARCHAR(50) NOT NULL DEFAULT '实质' COMMENT '元素维度'
  105. AFTER standard_element
  106. """
  107. )
  108. cur.execute("SHOW INDEX FROM video_element_feature_cache WHERE Key_name = 'uk_dt_video_point_element'")
  109. index_rows = cur.fetchall()
  110. index_cols = [str(row.get("Column_name") or "") for row in index_rows]
  111. index_ok = (
  112. index_cols == ["dt", "video_id", "element_dimension", "point_type", "standard_element"]
  113. and str((index_rows[-1] or {}).get("Sub_part") or "") == "191"
  114. )
  115. if index_rows and not index_ok:
  116. cur.execute("ALTER TABLE video_element_feature_cache DROP INDEX uk_dt_video_point_element")
  117. cur.execute("SHOW COLUMNS FROM video_element_feature_cache LIKE 'standard_element'")
  118. standard_col = cur.fetchone()
  119. if standard_col and "varchar(1024)" not in str(standard_col.get("Type") or "").lower():
  120. cur.execute(
  121. """
  122. ALTER TABLE video_element_feature_cache
  123. MODIFY COLUMN standard_element VARCHAR(1024) NOT NULL COMMENT '标准化元素或解构选题文本'
  124. """
  125. )
  126. cur.execute("SHOW COLUMNS FROM video_element_feature_cache LIKE 'is_miss'")
  127. if not cur.fetchone():
  128. cur.execute(
  129. """
  130. ALTER TABLE video_element_feature_cache
  131. ADD COLUMN is_miss BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否为无特征负缓存'
  132. AFTER contribution_score
  133. """
  134. )
  135. if not index_ok:
  136. cur.execute(
  137. """
  138. ALTER TABLE video_element_feature_cache
  139. ADD UNIQUE KEY uk_dt_video_point_element
  140. (dt, video_id, element_dimension, point_type, standard_element(191))
  141. """
  142. )
  143. finally:
  144. conn.close()
  145. def _read_cache(video_ids: list[int], dt: str) -> tuple[dict[int, list[VideoElementFeature]], set[int]]:
  146. """按 dt 从缓存表批量读特征。返回 (video_id→特征列表, 已缓存的 video_id 集合,含负缓存)。"""
  147. if not video_ids:
  148. return {}, set()
  149. from db.connection import get_connection
  150. out: dict[int, list[VideoElementFeature]] = {}
  151. cached_ids: set[int] = set()
  152. conn = get_connection()
  153. try:
  154. with conn.cursor() as cur:
  155. for batch in _chunks(video_ids, 500):
  156. placeholders = ",".join(["%s"] * len(batch))
  157. cur.execute(
  158. f"""
  159. SELECT video_id, dt, element_dimension, point_type, standard_element, contribution_score, is_miss
  160. FROM video_element_feature_cache
  161. WHERE dt = %s
  162. AND video_id IN ({placeholders})
  163. ORDER BY video_id, contribution_score DESC
  164. """,
  165. [dt, *batch],
  166. )
  167. for row in cur.fetchall():
  168. video_id = int(row["video_id"])
  169. cached_ids.add(video_id)
  170. if row.get("is_miss"):
  171. continue
  172. out.setdefault(video_id, []).append(VideoElementFeature(
  173. video_id=video_id,
  174. element_dimension=str(row["element_dimension"] or ""),
  175. point_type=str(row["point_type"] or ""),
  176. standard_element=str(row["standard_element"] or ""),
  177. contribution_score=float(row["contribution_score"] or 0.0),
  178. dt=str(row["dt"] or ""),
  179. ))
  180. finally:
  181. conn.close()
  182. return out, cached_ids
  183. def _write_cache(
  184. features: dict[int, list[VideoElementFeature]],
  185. miss_video_ids: Iterable[int],
  186. dt: str,
  187. ) -> None:
  188. """把 ODPS 查到的特征和无特征负缓存(is_miss=True)批量写入缓存表(upsert)。"""
  189. rows = [
  190. (
  191. feature.video_id,
  192. feature.dt,
  193. feature.point_type,
  194. feature.standard_element,
  195. feature.element_dimension,
  196. feature.contribution_score,
  197. False,
  198. )
  199. for feature_list in features.values()
  200. for feature in feature_list
  201. ]
  202. miss_rows = [
  203. (int(video_id), dt, "", "", "实质", 0.0, True)
  204. for video_id in miss_video_ids
  205. ]
  206. rows.extend(miss_rows)
  207. if not rows:
  208. return
  209. from db.connection import get_connection
  210. conn = get_connection()
  211. try:
  212. with conn.cursor() as cur:
  213. cur.executemany(
  214. """
  215. INSERT INTO video_element_feature_cache
  216. (video_id, dt, point_type, standard_element, element_dimension, contribution_score, is_miss)
  217. VALUES (%s, %s, %s, %s, %s, %s, %s)
  218. ON DUPLICATE KEY UPDATE
  219. contribution_score = VALUES(contribution_score),
  220. element_dimension = VALUES(element_dimension),
  221. is_miss = VALUES(is_miss),
  222. updated_at = CURRENT_TIMESTAMP
  223. """,
  224. rows,
  225. )
  226. finally:
  227. conn.close()
  228. def read_cached_video_element_features(
  229. video_ids: Iterable[int],
  230. ) -> dict[int, list[VideoElementFeature]]:
  231. """仅从本地数据库读取每个视频 ID 的最新缓存特征行。"""
  232. ids: list[int] = []
  233. seen: set[int] = set()
  234. for raw in video_ids:
  235. try:
  236. vid = int(raw)
  237. except (TypeError, ValueError):
  238. continue
  239. if vid in seen:
  240. continue
  241. seen.add(vid)
  242. ids.append(vid)
  243. if not ids:
  244. return {}
  245. _ensure_cache_table()
  246. from db.connection import get_connection
  247. out: dict[int, list[VideoElementFeature]] = {}
  248. conn = get_connection()
  249. try:
  250. with conn.cursor() as cur:
  251. for batch in _chunks(ids, 500):
  252. placeholders = ",".join(["%s"] * len(batch))
  253. params = [*batch, *batch]
  254. cur.execute(
  255. f"""
  256. SELECT c.video_id, c.dt, c.element_dimension, c.point_type,
  257. c.standard_element, c.contribution_score, c.is_miss
  258. FROM video_element_feature_cache c
  259. JOIN (
  260. SELECT video_id, MAX(dt) AS dt
  261. FROM video_element_feature_cache
  262. WHERE video_id IN ({placeholders})
  263. GROUP BY video_id
  264. ) latest ON latest.video_id = c.video_id AND latest.dt = c.dt
  265. WHERE c.video_id IN ({placeholders})
  266. ORDER BY c.video_id, c.contribution_score DESC
  267. """,
  268. params,
  269. )
  270. for row in cur.fetchall():
  271. if row.get("is_miss"):
  272. continue
  273. video_id = int(row["video_id"])
  274. out.setdefault(video_id, []).append(VideoElementFeature(
  275. video_id=video_id,
  276. element_dimension=str(row["element_dimension"] or ""),
  277. point_type=str(row["point_type"] or ""),
  278. standard_element=str(row["standard_element"] or ""),
  279. contribution_score=float(row["contribution_score"] or 0.0),
  280. dt=str(row["dt"] or ""),
  281. ))
  282. finally:
  283. conn.close()
  284. return out
  285. def fetch_video_element_features(
  286. video_ids: Iterable[int],
  287. chunk_size: int = 100,
  288. ) -> dict[int, list[VideoElementFeature]]:
  289. """从 ODPS 最新分区读取各视频 ID 的召回特征。
  290. 一个视频可以有多条可用元素,保留符合以下条件的全部记录:
  291. - 元素维度为“解构选题”;
  292. - 元素维度为“实质”,且点类型属于灵感点、关键点或目的点;
  293. - 贡献分不低于 0.8;
  294. - 标准化元素非空。
  295. """
  296. ids: list[int] = []
  297. seen: set[int] = set()
  298. for raw in video_ids:
  299. try:
  300. vid = int(raw)
  301. except (TypeError, ValueError):
  302. continue
  303. if vid in seen:
  304. continue
  305. seen.add(vid)
  306. ids.append(vid)
  307. if not ids:
  308. return {}
  309. client = _get_odps_client()
  310. if client is None:
  311. logger.warning("[video_feature] ODPS client unavailable, skip enrichment")
  312. return {}
  313. dt = _latest_partition_dt(client)
  314. _ensure_cache_table()
  315. out, cached_ids = _read_cache(ids, dt)
  316. missing_ids = [video_id for video_id in ids if video_id not in cached_ids]
  317. if not missing_ids:
  318. logger.info(
  319. "[video_feature] cache hit %d/%d videos dt=%s",
  320. len(cached_ids), len(ids), dt,
  321. )
  322. return out
  323. fetched: dict[int, list[VideoElementFeature]] = {}
  324. for batch in _chunks(missing_ids, max(1, chunk_size)):
  325. vid_list = ",".join(_quote_sql_string(str(v)) for v in batch)
  326. sql = f"""
  327. SELECT vid
  328. ,`元素维度` AS element_dimension
  329. ,`点类型` AS point_type
  330. ,`标准化元素` AS standard_element
  331. ,`解构选题` AS deconstruct_topic
  332. ,`贡献分` AS contribution_score
  333. ,dt
  334. FROM loghubods.dwd_video_element_contribution_analysis
  335. WHERE dt = {_quote_sql_string(dt)}
  336. AND vid IN ({vid_list})
  337. AND (
  338. (`元素维度` = '实质' AND `点类型` IN ('灵感点', '关键点', '目的点'))
  339. OR (`解构选题` IS NOT NULL AND `解构选题` <> '')
  340. )
  341. AND `贡献分` >= 0.8
  342. ORDER BY vid, `贡献分` DESC
  343. """
  344. rows = _query_odps_rows(
  345. client,
  346. sql,
  347. ["vid", "element_dimension", "point_type", "standard_element", "deconstruct_topic", "contribution_score", "dt"],
  348. )
  349. seen_features: set[tuple[int, str, str, str]] = set()
  350. for row in rows:
  351. try:
  352. video_id = int(row.get("vid"))
  353. except (TypeError, ValueError):
  354. continue
  355. element_dimension = str(row.get("element_dimension") or "").strip()
  356. point_type = str(row.get("point_type") or "").strip()
  357. standard_element = str(row.get("standard_element") or "").strip()
  358. contribution_score = float(row.get("contribution_score") or 0.0)
  359. dt_value = str(row.get("dt") or "")
  360. if (
  361. element_dimension == "实质"
  362. and point_type in {"灵感点", "关键点", "目的点"}
  363. and standard_element
  364. ):
  365. key = (video_id, element_dimension, point_type, standard_element)
  366. if key not in seen_features:
  367. seen_features.add(key)
  368. fetched.setdefault(video_id, []).append(VideoElementFeature(
  369. video_id=video_id,
  370. element_dimension=element_dimension,
  371. point_type=point_type,
  372. standard_element=standard_element,
  373. contribution_score=contribution_score,
  374. dt=dt_value,
  375. ))
  376. topic = str(row.get("deconstruct_topic") or "").strip()
  377. if topic:
  378. key = (video_id, "解构选题", "", topic)
  379. if key not in seen_features:
  380. seen_features.add(key)
  381. fetched.setdefault(video_id, []).append(VideoElementFeature(
  382. video_id=video_id,
  383. element_dimension="解构选题",
  384. point_type="",
  385. standard_element=topic,
  386. contribution_score=contribution_score,
  387. dt=dt_value,
  388. ))
  389. fetched_ids = set(fetched.keys())
  390. miss_ids = [video_id for video_id in missing_ids if video_id not in fetched_ids]
  391. _write_cache(fetched, miss_ids, dt)
  392. out.update(fetched)
  393. logger.info(
  394. "[video_feature] dt=%s cache_hit=%d odps_hit=%d odps_miss=%d/%d element_rows=%d",
  395. dt, len(cached_ids), len(fetched), len(miss_ids), len(missing_ids),
  396. sum(len(v) for v in out.values()),
  397. )
  398. return out