video_feature_query.py 16 KB

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