video_feature_query.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. """ODPS video feature lookup for landing-video material recall.
  2. videoContentList only chooses landing videos. Material recall features are read
  3. from loghubods.dwd_video_element_contribution_analysis by video id.
  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. """Read ODPS rows without tunnel when possible.
  79. The configured tunnel endpoint can be unreachable in some runtime
  80. environments. Small feature lookups are better served through the instance
  81. reader without tunnel.
  82. """
  83. odps = getattr(client, "_odps", None)
  84. if odps is None:
  85. return client.query(sql)
  86. instance = odps.execute_sql(sql)
  87. instance.wait_for_success()
  88. rows: list[dict] = []
  89. with instance.open_reader(tunnel=False) as reader:
  90. for record in reader:
  91. rows.append({col: record[i] for i, col in enumerate(columns)})
  92. return rows
  93. def _ensure_cache_table() -> None:
  94. """确保 MySQL 缓存表 video_element_feature_cache 存在且结构、索引符合预期(幂等迁移)。"""
  95. from db.connection import get_connection
  96. conn = get_connection()
  97. try:
  98. with conn.cursor() as cur:
  99. cur.execute(CREATE_CACHE_TABLE_SQL)
  100. cur.execute("SHOW COLUMNS FROM video_element_feature_cache LIKE 'element_dimension'")
  101. if not cur.fetchone():
  102. cur.execute(
  103. """
  104. ALTER TABLE video_element_feature_cache
  105. ADD COLUMN element_dimension VARCHAR(50) NOT NULL DEFAULT '实质' COMMENT '元素维度'
  106. AFTER standard_element
  107. """
  108. )
  109. cur.execute("SHOW INDEX FROM video_element_feature_cache WHERE Key_name = 'uk_dt_video_point_element'")
  110. index_rows = cur.fetchall()
  111. index_cols = [str(row.get("Column_name") or "") for row in index_rows]
  112. index_ok = (
  113. index_cols == ["dt", "video_id", "element_dimension", "point_type", "standard_element"]
  114. and str((index_rows[-1] or {}).get("Sub_part") or "") == "191"
  115. )
  116. if index_rows and not index_ok:
  117. cur.execute("ALTER TABLE video_element_feature_cache DROP INDEX uk_dt_video_point_element")
  118. cur.execute("SHOW COLUMNS FROM video_element_feature_cache LIKE 'standard_element'")
  119. standard_col = cur.fetchone()
  120. if standard_col and "varchar(1024)" not in str(standard_col.get("Type") or "").lower():
  121. cur.execute(
  122. """
  123. ALTER TABLE video_element_feature_cache
  124. MODIFY COLUMN standard_element VARCHAR(1024) NOT NULL COMMENT '标准化元素或解构选题文本'
  125. """
  126. )
  127. cur.execute("SHOW COLUMNS FROM video_element_feature_cache LIKE 'is_miss'")
  128. if not cur.fetchone():
  129. cur.execute(
  130. """
  131. ALTER TABLE video_element_feature_cache
  132. ADD COLUMN is_miss BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否为无特征负缓存'
  133. AFTER contribution_score
  134. """
  135. )
  136. if not index_ok:
  137. cur.execute(
  138. """
  139. ALTER TABLE video_element_feature_cache
  140. ADD UNIQUE KEY uk_dt_video_point_element
  141. (dt, video_id, element_dimension, point_type, standard_element(191))
  142. """
  143. )
  144. finally:
  145. conn.close()
  146. def _read_cache(video_ids: list[int], dt: str) -> tuple[dict[int, list[VideoElementFeature]], set[int]]:
  147. """按 dt 从缓存表批量读特征。返回 (video_id→特征列表, 已缓存的 video_id 集合,含负缓存)。"""
  148. if not video_ids:
  149. return {}, set()
  150. from db.connection import get_connection
  151. out: dict[int, list[VideoElementFeature]] = {}
  152. cached_ids: set[int] = set()
  153. conn = get_connection()
  154. try:
  155. with conn.cursor() as cur:
  156. for batch in _chunks(video_ids, 500):
  157. placeholders = ",".join(["%s"] * len(batch))
  158. cur.execute(
  159. f"""
  160. SELECT video_id, dt, element_dimension, point_type, standard_element, contribution_score, is_miss
  161. FROM video_element_feature_cache
  162. WHERE dt = %s
  163. AND video_id IN ({placeholders})
  164. ORDER BY video_id, contribution_score DESC
  165. """,
  166. [dt, *batch],
  167. )
  168. for row in cur.fetchall():
  169. video_id = int(row["video_id"])
  170. cached_ids.add(video_id)
  171. if row.get("is_miss"):
  172. continue
  173. out.setdefault(video_id, []).append(VideoElementFeature(
  174. video_id=video_id,
  175. element_dimension=str(row["element_dimension"] or ""),
  176. point_type=str(row["point_type"] or ""),
  177. standard_element=str(row["standard_element"] or ""),
  178. contribution_score=float(row["contribution_score"] or 0.0),
  179. dt=str(row["dt"] or ""),
  180. ))
  181. finally:
  182. conn.close()
  183. return out, cached_ids
  184. def _write_cache(
  185. features: dict[int, list[VideoElementFeature]],
  186. miss_video_ids: Iterable[int],
  187. dt: str,
  188. ) -> None:
  189. """把 ODPS 查到的特征和无特征负缓存(is_miss=True)批量写入缓存表(upsert)。"""
  190. rows = [
  191. (
  192. feature.video_id,
  193. feature.dt,
  194. feature.point_type,
  195. feature.standard_element,
  196. feature.element_dimension,
  197. feature.contribution_score,
  198. False,
  199. )
  200. for feature_list in features.values()
  201. for feature in feature_list
  202. ]
  203. miss_rows = [
  204. (int(video_id), dt, "", "", "实质", 0.0, True)
  205. for video_id in miss_video_ids
  206. ]
  207. rows.extend(miss_rows)
  208. if not rows:
  209. return
  210. from db.connection import get_connection
  211. conn = get_connection()
  212. try:
  213. with conn.cursor() as cur:
  214. cur.executemany(
  215. """
  216. INSERT INTO video_element_feature_cache
  217. (video_id, dt, point_type, standard_element, element_dimension, contribution_score, is_miss)
  218. VALUES (%s, %s, %s, %s, %s, %s, %s)
  219. ON DUPLICATE KEY UPDATE
  220. contribution_score = VALUES(contribution_score),
  221. element_dimension = VALUES(element_dimension),
  222. is_miss = VALUES(is_miss),
  223. updated_at = CURRENT_TIMESTAMP
  224. """,
  225. rows,
  226. )
  227. finally:
  228. conn.close()
  229. def read_cached_video_element_features(
  230. video_ids: Iterable[int],
  231. ) -> dict[int, list[VideoElementFeature]]:
  232. """Read latest cached feature rows per video id from local DB only."""
  233. ids: list[int] = []
  234. seen: set[int] = set()
  235. for raw in video_ids:
  236. try:
  237. vid = int(raw)
  238. except (TypeError, ValueError):
  239. continue
  240. if vid in seen:
  241. continue
  242. seen.add(vid)
  243. ids.append(vid)
  244. if not ids:
  245. return {}
  246. _ensure_cache_table()
  247. from db.connection import get_connection
  248. out: dict[int, list[VideoElementFeature]] = {}
  249. conn = get_connection()
  250. try:
  251. with conn.cursor() as cur:
  252. for batch in _chunks(ids, 500):
  253. placeholders = ",".join(["%s"] * len(batch))
  254. params = [*batch, *batch]
  255. cur.execute(
  256. f"""
  257. SELECT c.video_id, c.dt, c.element_dimension, c.point_type,
  258. c.standard_element, c.contribution_score, c.is_miss
  259. FROM video_element_feature_cache c
  260. JOIN (
  261. SELECT video_id, MAX(dt) AS dt
  262. FROM video_element_feature_cache
  263. WHERE video_id IN ({placeholders})
  264. GROUP BY video_id
  265. ) latest ON latest.video_id = c.video_id AND latest.dt = c.dt
  266. WHERE c.video_id IN ({placeholders})
  267. ORDER BY c.video_id, c.contribution_score DESC
  268. """,
  269. params,
  270. )
  271. for row in cur.fetchall():
  272. if row.get("is_miss"):
  273. continue
  274. video_id = int(row["video_id"])
  275. out.setdefault(video_id, []).append(VideoElementFeature(
  276. video_id=video_id,
  277. element_dimension=str(row["element_dimension"] or ""),
  278. point_type=str(row["point_type"] or ""),
  279. standard_element=str(row["standard_element"] or ""),
  280. contribution_score=float(row["contribution_score"] or 0.0),
  281. dt=str(row["dt"] or ""),
  282. ))
  283. finally:
  284. conn.close()
  285. return out
  286. def fetch_video_element_features(
  287. video_ids: Iterable[int],
  288. chunk_size: int = 100,
  289. ) -> dict[int, list[VideoElementFeature]]:
  290. """Fetch recall feature rows per video id from latest ODPS partition.
  291. One video can have multiple usable element rows. Keep all rows where:
  292. - 元素维度 = 解构选题
  293. - 元素维度 = 实质 and 点类型 in 灵感点/关键点/目的点
  294. - 贡献分 >= 0.8
  295. - 标准化元素 is non-empty
  296. """
  297. ids: list[int] = []
  298. seen: set[int] = set()
  299. for raw in video_ids:
  300. try:
  301. vid = int(raw)
  302. except (TypeError, ValueError):
  303. continue
  304. if vid in seen:
  305. continue
  306. seen.add(vid)
  307. ids.append(vid)
  308. if not ids:
  309. return {}
  310. client = _get_odps_client()
  311. if client is None:
  312. logger.warning("[video_feature] ODPS client unavailable, skip enrichment")
  313. return {}
  314. dt = _latest_partition_dt(client)
  315. _ensure_cache_table()
  316. out, cached_ids = _read_cache(ids, dt)
  317. missing_ids = [video_id for video_id in ids if video_id not in cached_ids]
  318. if not missing_ids:
  319. logger.info(
  320. "[video_feature] cache hit %d/%d videos dt=%s",
  321. len(cached_ids), len(ids), dt,
  322. )
  323. return out
  324. fetched: dict[int, list[VideoElementFeature]] = {}
  325. for batch in _chunks(missing_ids, max(1, chunk_size)):
  326. vid_list = ",".join(_quote_sql_string(str(v)) for v in batch)
  327. sql = f"""
  328. SELECT vid
  329. ,`元素维度` AS element_dimension
  330. ,`点类型` AS point_type
  331. ,`标准化元素` AS standard_element
  332. ,`解构选题` AS deconstruct_topic
  333. ,`贡献分` AS contribution_score
  334. ,dt
  335. FROM loghubods.dwd_video_element_contribution_analysis
  336. WHERE dt = {_quote_sql_string(dt)}
  337. AND vid IN ({vid_list})
  338. AND (
  339. (`元素维度` = '实质' AND `点类型` IN ('灵感点', '关键点', '目的点'))
  340. OR (`解构选题` IS NOT NULL AND `解构选题` <> '')
  341. )
  342. AND `贡献分` >= 0.8
  343. ORDER BY vid, `贡献分` DESC
  344. """
  345. rows = _query_odps_rows(
  346. client,
  347. sql,
  348. ["vid", "element_dimension", "point_type", "standard_element", "deconstruct_topic", "contribution_score", "dt"],
  349. )
  350. seen_features: set[tuple[int, str, str, str]] = set()
  351. for row in rows:
  352. try:
  353. video_id = int(row.get("vid"))
  354. except (TypeError, ValueError):
  355. continue
  356. element_dimension = str(row.get("element_dimension") or "").strip()
  357. point_type = str(row.get("point_type") or "").strip()
  358. standard_element = str(row.get("standard_element") or "").strip()
  359. contribution_score = float(row.get("contribution_score") or 0.0)
  360. dt_value = str(row.get("dt") or "")
  361. if (
  362. element_dimension == "实质"
  363. and point_type in {"灵感点", "关键点", "目的点"}
  364. and standard_element
  365. ):
  366. key = (video_id, element_dimension, point_type, standard_element)
  367. if key not in seen_features:
  368. seen_features.add(key)
  369. fetched.setdefault(video_id, []).append(VideoElementFeature(
  370. video_id=video_id,
  371. element_dimension=element_dimension,
  372. point_type=point_type,
  373. standard_element=standard_element,
  374. contribution_score=contribution_score,
  375. dt=dt_value,
  376. ))
  377. topic = str(row.get("deconstruct_topic") or "").strip()
  378. if topic:
  379. key = (video_id, "解构选题", "", topic)
  380. if key not in seen_features:
  381. seen_features.add(key)
  382. fetched.setdefault(video_id, []).append(VideoElementFeature(
  383. video_id=video_id,
  384. element_dimension="解构选题",
  385. point_type="",
  386. standard_element=topic,
  387. contribution_score=contribution_score,
  388. dt=dt_value,
  389. ))
  390. fetched_ids = set(fetched.keys())
  391. miss_ids = [video_id for video_id in missing_ids if video_id not in fetched_ids]
  392. _write_cache(fetched, miss_ids, dt)
  393. out.update(fetched)
  394. logger.info(
  395. "[video_feature] dt=%s cache_hit=%d odps_hit=%d odps_miss=%d/%d element_rows=%d",
  396. dt, len(cached_ids), len(fetched), len(miss_ids), len(missing_ids),
  397. sum(len(v) for v in out.values()),
  398. )
  399. return out