| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438 |
- """ODPS video feature lookup for landing-video material recall.
- videoContentList only chooses landing videos. Material recall features are read
- from loghubods.dwd_video_element_contribution_analysis by video id.
- """
- from __future__ import annotations
- import logging
- from dataclasses import dataclass
- from pathlib import Path
- from typing import Iterable
- logger = logging.getLogger(__name__)
- _MINI_DIR = Path(__file__).resolve().parent.parent
- @dataclass(frozen=True)
- class VideoElementFeature:
- video_id: int
- element_dimension: str
- point_type: str
- standard_element: str
- contribution_score: float
- dt: str
- CREATE_CACHE_TABLE_SQL = """
- CREATE TABLE IF NOT EXISTS video_element_feature_cache (
- id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
- video_id BIGINT NOT NULL COMMENT '业务视频ID,对应 ODPS vid',
- dt VARCHAR(8) NOT NULL COMMENT 'ODPS 分区日期 YYYYMMDD',
- point_type VARCHAR(50) NOT NULL COMMENT '点类型',
- standard_element VARCHAR(1024) NOT NULL COMMENT '标准化元素或解构选题文本',
- element_dimension VARCHAR(50) NOT NULL DEFAULT '实质' COMMENT '元素维度',
- contribution_score DOUBLE NOT NULL DEFAULT 0 COMMENT '贡献分',
- is_miss BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否为无特征负缓存',
- raw_element VARCHAR(255) DEFAULT NULL COMMENT '原始元素名称',
- element_id VARCHAR(100) DEFAULT NULL COMMENT '标准化元素ID',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- UNIQUE KEY uk_dt_video_point_element (dt, video_id, element_dimension, point_type, standard_element(191)),
- KEY idx_video_dt (video_id, dt),
- KEY idx_dt (dt),
- KEY idx_updated_at (updated_at)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频元素特征缓存'
- """
- def _quote_sql_string(value: str) -> str:
- return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
- def _chunks(items: list[int], size: int) -> Iterable[list[int]]:
- for i in range(0, len(items), size):
- yield items[i:i + size]
- def _get_odps_client():
- import sys
- tools_dir = _MINI_DIR / "tools"
- if str(tools_dir) not in sys.path:
- sys.path.insert(0, str(tools_dir))
- from odps_module import get_odps_client
- return get_odps_client(project="loghubods")
- def _latest_partition_dt(client) -> str:
- table_name = "dwd_video_element_contribution_analysis"
- try:
- table = client._odps.get_table(table_name)
- partition = table.get_max_partition()
- if partition is not None:
- name = getattr(partition, "name", "") or str(partition)
- for part in name.split(","):
- if part.startswith("dt="):
- return part.split("=", 1)[1].strip("'\"")
- except Exception as e:
- logger.warning("[video_feature] get max partition by metadata failed:%s", e)
- rows = client.query(
- "SELECT MAX_PT('loghubods.dwd_video_element_contribution_analysis') AS dt",
- limit=1,
- )
- if rows and rows[0].get("dt"):
- return str(rows[0]["dt"])
- raise RuntimeError("无法获取 dwd_video_element_contribution_analysis 最新分区")
- def _query_odps_rows(client, sql: str, columns: list[str]) -> list[dict]:
- """Read ODPS rows without tunnel when possible.
- The configured tunnel endpoint can be unreachable in some runtime
- environments. Small feature lookups are better served through the instance
- reader without tunnel.
- """
- odps = getattr(client, "_odps", None)
- if odps is None:
- return client.query(sql)
- instance = odps.execute_sql(sql)
- instance.wait_for_success()
- rows: list[dict] = []
- with instance.open_reader(tunnel=False) as reader:
- for record in reader:
- rows.append({col: record[i] for i, col in enumerate(columns)})
- return rows
- def _ensure_cache_table() -> None:
- from db.connection import get_connection
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.execute(CREATE_CACHE_TABLE_SQL)
- cur.execute("SHOW COLUMNS FROM video_element_feature_cache LIKE 'element_dimension'")
- if not cur.fetchone():
- cur.execute(
- """
- ALTER TABLE video_element_feature_cache
- ADD COLUMN element_dimension VARCHAR(50) NOT NULL DEFAULT '实质' COMMENT '元素维度'
- AFTER standard_element
- """
- )
- cur.execute("SHOW INDEX FROM video_element_feature_cache WHERE Key_name = 'uk_dt_video_point_element'")
- index_rows = cur.fetchall()
- index_cols = [str(row.get("Column_name") or "") for row in index_rows]
- index_ok = (
- index_cols == ["dt", "video_id", "element_dimension", "point_type", "standard_element"]
- and str((index_rows[-1] or {}).get("Sub_part") or "") == "191"
- )
- if index_rows and not index_ok:
- cur.execute("ALTER TABLE video_element_feature_cache DROP INDEX uk_dt_video_point_element")
- cur.execute("SHOW COLUMNS FROM video_element_feature_cache LIKE 'standard_element'")
- standard_col = cur.fetchone()
- if standard_col and "varchar(1024)" not in str(standard_col.get("Type") or "").lower():
- cur.execute(
- """
- ALTER TABLE video_element_feature_cache
- MODIFY COLUMN standard_element VARCHAR(1024) NOT NULL COMMENT '标准化元素或解构选题文本'
- """
- )
- cur.execute("SHOW COLUMNS FROM video_element_feature_cache LIKE 'is_miss'")
- if not cur.fetchone():
- cur.execute(
- """
- ALTER TABLE video_element_feature_cache
- ADD COLUMN is_miss BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否为无特征负缓存'
- AFTER contribution_score
- """
- )
- if not index_ok:
- cur.execute(
- """
- ALTER TABLE video_element_feature_cache
- ADD UNIQUE KEY uk_dt_video_point_element
- (dt, video_id, element_dimension, point_type, standard_element(191))
- """
- )
- finally:
- conn.close()
- def _read_cache(video_ids: list[int], dt: str) -> tuple[dict[int, list[VideoElementFeature]], set[int]]:
- if not video_ids:
- return {}, set()
- from db.connection import get_connection
- out: dict[int, list[VideoElementFeature]] = {}
- cached_ids: set[int] = set()
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- for batch in _chunks(video_ids, 500):
- placeholders = ",".join(["%s"] * len(batch))
- cur.execute(
- f"""
- SELECT video_id, dt, element_dimension, point_type, standard_element, contribution_score, is_miss
- FROM video_element_feature_cache
- WHERE dt = %s
- AND video_id IN ({placeholders})
- ORDER BY video_id, contribution_score DESC
- """,
- [dt, *batch],
- )
- for row in cur.fetchall():
- video_id = int(row["video_id"])
- cached_ids.add(video_id)
- if row.get("is_miss"):
- continue
- out.setdefault(video_id, []).append(VideoElementFeature(
- video_id=video_id,
- element_dimension=str(row["element_dimension"] or ""),
- point_type=str(row["point_type"] or ""),
- standard_element=str(row["standard_element"] or ""),
- contribution_score=float(row["contribution_score"] or 0.0),
- dt=str(row["dt"] or ""),
- ))
- finally:
- conn.close()
- return out, cached_ids
- def _write_cache(
- features: dict[int, list[VideoElementFeature]],
- miss_video_ids: Iterable[int],
- dt: str,
- ) -> None:
- rows = [
- (
- feature.video_id,
- feature.dt,
- feature.point_type,
- feature.standard_element,
- feature.element_dimension,
- feature.contribution_score,
- False,
- )
- for feature_list in features.values()
- for feature in feature_list
- ]
- miss_rows = [
- (int(video_id), dt, "", "", "实质", 0.0, True)
- for video_id in miss_video_ids
- ]
- rows.extend(miss_rows)
- if not rows:
- return
- from db.connection import get_connection
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.executemany(
- """
- INSERT INTO video_element_feature_cache
- (video_id, dt, point_type, standard_element, element_dimension, contribution_score, is_miss)
- VALUES (%s, %s, %s, %s, %s, %s, %s)
- ON DUPLICATE KEY UPDATE
- contribution_score = VALUES(contribution_score),
- element_dimension = VALUES(element_dimension),
- is_miss = VALUES(is_miss),
- updated_at = CURRENT_TIMESTAMP
- """,
- rows,
- )
- finally:
- conn.close()
- def read_cached_video_element_features(
- video_ids: Iterable[int],
- ) -> dict[int, list[VideoElementFeature]]:
- """Read latest cached feature rows per video id from local DB only."""
- ids: list[int] = []
- seen: set[int] = set()
- for raw in video_ids:
- try:
- vid = int(raw)
- except (TypeError, ValueError):
- continue
- if vid in seen:
- continue
- seen.add(vid)
- ids.append(vid)
- if not ids:
- return {}
- _ensure_cache_table()
- from db.connection import get_connection
- out: dict[int, list[VideoElementFeature]] = {}
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- for batch in _chunks(ids, 500):
- placeholders = ",".join(["%s"] * len(batch))
- params = [*batch, *batch]
- cur.execute(
- f"""
- SELECT c.video_id, c.dt, c.element_dimension, c.point_type,
- c.standard_element, c.contribution_score, c.is_miss
- FROM video_element_feature_cache c
- JOIN (
- SELECT video_id, MAX(dt) AS dt
- FROM video_element_feature_cache
- WHERE video_id IN ({placeholders})
- GROUP BY video_id
- ) latest ON latest.video_id = c.video_id AND latest.dt = c.dt
- WHERE c.video_id IN ({placeholders})
- ORDER BY c.video_id, c.contribution_score DESC
- """,
- params,
- )
- for row in cur.fetchall():
- if row.get("is_miss"):
- continue
- video_id = int(row["video_id"])
- out.setdefault(video_id, []).append(VideoElementFeature(
- video_id=video_id,
- element_dimension=str(row["element_dimension"] or ""),
- point_type=str(row["point_type"] or ""),
- standard_element=str(row["standard_element"] or ""),
- contribution_score=float(row["contribution_score"] or 0.0),
- dt=str(row["dt"] or ""),
- ))
- finally:
- conn.close()
- return out
- def fetch_video_element_features(
- video_ids: Iterable[int],
- chunk_size: int = 100,
- ) -> dict[int, list[VideoElementFeature]]:
- """Fetch recall feature rows per video id from latest ODPS partition.
- One video can have multiple usable element rows. Keep all rows where:
- - 元素维度 = 解构选题
- - 元素维度 = 实质 and 点类型 in 灵感点/关键点/目的点
- - 贡献分 >= 0.8
- - 标准化元素 is non-empty
- """
- ids: list[int] = []
- seen: set[int] = set()
- for raw in video_ids:
- try:
- vid = int(raw)
- except (TypeError, ValueError):
- continue
- if vid in seen:
- continue
- seen.add(vid)
- ids.append(vid)
- if not ids:
- return {}
- client = _get_odps_client()
- if client is None:
- logger.warning("[video_feature] ODPS client unavailable, skip enrichment")
- return {}
- dt = _latest_partition_dt(client)
- _ensure_cache_table()
- out, cached_ids = _read_cache(ids, dt)
- missing_ids = [video_id for video_id in ids if video_id not in cached_ids]
- if not missing_ids:
- logger.info(
- "[video_feature] cache hit %d/%d videos dt=%s",
- len(cached_ids), len(ids), dt,
- )
- return out
- fetched: dict[int, list[VideoElementFeature]] = {}
- for batch in _chunks(missing_ids, max(1, chunk_size)):
- vid_list = ",".join(_quote_sql_string(str(v)) for v in batch)
- sql = f"""
- SELECT vid
- ,`元素维度` AS element_dimension
- ,`点类型` AS point_type
- ,`标准化元素` AS standard_element
- ,`解构选题` AS deconstruct_topic
- ,`贡献分` AS contribution_score
- ,dt
- FROM loghubods.dwd_video_element_contribution_analysis
- WHERE dt = {_quote_sql_string(dt)}
- AND vid IN ({vid_list})
- AND (
- (`元素维度` = '实质' AND `点类型` IN ('灵感点', '关键点', '目的点'))
- OR (`解构选题` IS NOT NULL AND `解构选题` <> '')
- )
- AND `贡献分` >= 0.8
- ORDER BY vid, `贡献分` DESC
- """
- rows = _query_odps_rows(
- client,
- sql,
- ["vid", "element_dimension", "point_type", "standard_element", "deconstruct_topic", "contribution_score", "dt"],
- )
- seen_features: set[tuple[int, str, str, str]] = set()
- for row in rows:
- try:
- video_id = int(row.get("vid"))
- except (TypeError, ValueError):
- continue
- element_dimension = str(row.get("element_dimension") or "").strip()
- point_type = str(row.get("point_type") or "").strip()
- standard_element = str(row.get("standard_element") or "").strip()
- contribution_score = float(row.get("contribution_score") or 0.0)
- dt_value = str(row.get("dt") or "")
- if (
- element_dimension == "实质"
- and point_type in {"灵感点", "关键点", "目的点"}
- and standard_element
- ):
- key = (video_id, element_dimension, point_type, standard_element)
- if key not in seen_features:
- seen_features.add(key)
- fetched.setdefault(video_id, []).append(VideoElementFeature(
- video_id=video_id,
- element_dimension=element_dimension,
- point_type=point_type,
- standard_element=standard_element,
- contribution_score=contribution_score,
- dt=dt_value,
- ))
- topic = str(row.get("deconstruct_topic") or "").strip()
- if topic:
- key = (video_id, "解构选题", "", topic)
- if key not in seen_features:
- seen_features.add(key)
- fetched.setdefault(video_id, []).append(VideoElementFeature(
- video_id=video_id,
- element_dimension="解构选题",
- point_type="",
- standard_element=topic,
- contribution_score=contribution_score,
- dt=dt_value,
- ))
- fetched_ids = set(fetched.keys())
- miss_ids = [video_id for video_id in missing_ids if video_id not in fetched_ids]
- _write_cache(fetched, miss_ids, dt)
- out.update(fetched)
- logger.info(
- "[video_feature] dt=%s cache_hit=%d odps_hit=%d odps_miss=%d/%d element_rows=%d",
- dt, len(cached_ids), len(fetched), len(miss_ids), len(missing_ids),
- sum(len(v) for v in out.values()),
- )
- return out
|