sync.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. """
  2. Demand pool ODPS → MySQL sync stages used by the durable pipeline.
  3. Stages owned by this module:
  4. 1. source row sync (`_sync_pool_rows`)
  5. 2. demand word classification (`_classify_words`)
  6. 3. real rov/vov enrichment (`enrich_real_rov_vov_7d`)
  7. 4. popularity stats (`compute_popularity_stats`)
  8. Related pipeline stages live in sibling modules (belong_rel / tree_weight / videos).
  9. """
  10. from __future__ import annotations
  11. import json
  12. import logging
  13. from datetime import datetime, timedelta
  14. from decimal import Decimal
  15. from typing import Any
  16. from agents.demand_belong_category_agent.run import main as classify_demand_words
  17. from supply_infra.db.repositories.demand_belong_category_repo import (
  18. DemandBelongCategoryRepository,
  19. )
  20. from supply_infra.db.repositories.demand_popularity_stats_repo import (
  21. DemandPopularityStatsRepository,
  22. )
  23. from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
  24. from supply_infra.db.session import get_session
  25. from supply_infra.odps.client import get_odps_client
  26. logger = logging.getLogger(__name__)
  27. _WORD_BATCH_SIZE = 50
  28. _STATS_UPSERT_BATCH = 200
  29. _REAL_METRIC_LIMIT = 1000
  30. _REAL_METRIC_LOOKBACK_DAYS = 7
  31. _GLOBAL_FEATURE_VALUE = "全局SUM"
  32. _VIDEO_LIST_LIMIT = 10
  33. # 策略名 → 统计字段前缀;去年同期阳历/阴历合并为 plat_ly_pop
  34. _STRATEGY_METRIC: dict[str, str] = {
  35. "新热事件": "ext_pop",
  36. "逐月": "plat_sust_pop",
  37. "去年同期阳历": "plat_ly_pop",
  38. "去年同期阴历": "plat_ly_pop",
  39. "当下供需gap": "recent_pop",
  40. }
  41. _TARGET_STRATEGIES = list(_STRATEGY_METRIC.keys())
  42. _METRIC_KEYS = ("ext_pop", "plat_sust_pop", "plat_ly_pop", "recent_pop")
  43. _REAL_METRIC_KEYS = ("real_rov_7d", "real_vov_7d")
  44. _ALL_METRIC_KEYS = _METRIC_KEYS + _REAL_METRIC_KEYS
  45. _METRIC_DECIMAL_PLACES: dict[str, int] = {
  46. "ext_pop": 2,
  47. "plat_sust_pop": 2,
  48. "plat_ly_pop": 2,
  49. "recent_pop": 2,
  50. "real_rov_7d": 4,
  51. "real_vov_7d": 4,
  52. }
  53. RowKey = tuple[str, str]
  54. def _row_key(row: dict[str, Any]) -> RowKey:
  55. return (row["strategy"], row["demand_id"])
  56. def _normalize_video_list(raw: Any) -> tuple[str | None, int]:
  57. """取前 N 个 video_id,返回 (JSON 文本, count),二者保持一致。"""
  58. if raw is None:
  59. return None, 0
  60. items: list[Any]
  61. if isinstance(raw, str):
  62. text = raw.strip()
  63. if not text:
  64. return None, 0
  65. try:
  66. parsed = json.loads(text)
  67. items = list(parsed) if isinstance(parsed, list) else [text]
  68. except json.JSONDecodeError:
  69. items = [part.strip() for part in text.split(",") if part.strip()]
  70. elif isinstance(raw, (list, tuple)):
  71. items = list(raw)
  72. else:
  73. try:
  74. items = list(raw)
  75. except TypeError:
  76. return None, 0
  77. truncated = [
  78. str(v).strip()
  79. for v in items[:_VIDEO_LIST_LIMIT]
  80. if v is not None and str(v).strip()
  81. ]
  82. if not truncated:
  83. return None, 0
  84. return json.dumps(truncated, ensure_ascii=False), len(truncated)
  85. def _normalize_weight(raw: Any) -> float | None:
  86. """weight 为 0 或无效时视为无分数,存 null。"""
  87. if raw is None:
  88. return None
  89. try:
  90. value = float(raw)
  91. except (TypeError, ValueError):
  92. return None
  93. return None if value == 0 else value
  94. def _to_mysql_rows(raw_rows: list[dict[str, Any]], biz_dt: str) -> list[dict[str, Any]]:
  95. """转换为 MySQL 行,按 (strategy, demand_id) 去重(保留最后一条)。"""
  96. by_key: dict[RowKey, dict[str, Any]] = {}
  97. for row in raw_rows:
  98. strategy = row.get("strategy")
  99. demand_id = row.get("demand_id")
  100. demand_name = row.get("demand_name")
  101. if strategy is None or demand_id is None or demand_name is None:
  102. logger.warning("Skip row with missing required fields: %s", row)
  103. continue
  104. video_list, video_count = _normalize_video_list(row.get("video_list"))
  105. mapped = {
  106. "strategy": str(strategy),
  107. "demand_id": str(demand_id),
  108. "demand_name": str(demand_name),
  109. "weight": _normalize_weight(row.get("weight")),
  110. "type": str(row["type"]) if row.get("type") is not None else None,
  111. "video_count": video_count,
  112. "video_list": video_list,
  113. "extend": str(row["extend"]) if row.get("extend") is not None else None,
  114. "biz_dt": biz_dt,
  115. }
  116. by_key[_row_key(mapped)] = mapped
  117. return list(by_key.values())
  118. def _build_word_set(demand_names: list[str]) -> set[str]:
  119. """demand_name 按空格分词,全部落入同一个 set。"""
  120. words: set[str] = set()
  121. for name in demand_names:
  122. for token in str(name).split():
  123. word = token.strip()
  124. if word:
  125. words.add(word)
  126. return words
  127. def _chunked(items: list[str], size: int) -> list[list[str]]:
  128. return [items[i : i + size] for i in range(0, len(items), size)]
  129. def _classify_words(biz_dt: str) -> dict:
  130. """查询 demand_name → 空格分词入 set → 过滤已有词 → 100 词一批调用 agent。"""
  131. with get_session() as session:
  132. demand_names = MultiDemandPoolDiRepository(session).list_demand_names_by_biz_dt(biz_dt)
  133. word_set = _build_word_set(demand_names)
  134. existing = DemandBelongCategoryRepository(session).get_existing_names(word_set)
  135. pending = word_set - existing
  136. word_list = list(pending)
  137. batches = _chunked(word_list, _WORD_BATCH_SIZE)
  138. logger.info(
  139. "Classify prepare: demand_names=%d words=%d existing=%d pending=%d batches=%d",
  140. len(demand_names),
  141. len(word_set),
  142. len(existing),
  143. len(pending),
  144. len(batches),
  145. )
  146. failed_batches: list[dict[str, Any]] = []
  147. for idx, batch in enumerate(batches, start=1):
  148. logger.info("Classifying batch %d/%d (%d words)", idx, len(batches), len(batch))
  149. try:
  150. classify_demand_words(batch)
  151. except Exception as exc:
  152. logger.exception(
  153. "Demand belong classification batch failed; continue remaining batches: "
  154. "biz_dt=%s batch=%s/%s",
  155. biz_dt,
  156. idx,
  157. len(batches),
  158. )
  159. failed_batches.append(
  160. {
  161. "batch": idx,
  162. "size": len(batch),
  163. "error": str(exc),
  164. }
  165. )
  166. return {
  167. "success": not failed_batches,
  168. "demand_names": len(demand_names),
  169. "words": len(word_set),
  170. "existing_filtered": len(existing),
  171. "pending": len(pending),
  172. "batches": len(batches),
  173. "failed_batches": failed_batches,
  174. }
  175. def _sync_diff(partition_date: str) -> dict[str, Any]:
  176. """行数不同时拉取 ODPS,只同步 (strategy, demand_id) 差异,并回填已有行的 video/weight 字段。"""
  177. odps = get_odps_client()
  178. raw_rows = odps.fetch_multi_demand_pool(partition_date)
  179. mysql_rows = _to_mysql_rows(raw_rows, partition_date)
  180. odps_keys = {_row_key(r) for r in mysql_rows}
  181. odps_by_key = {_row_key(r): r for r in mysql_rows}
  182. with get_session() as session:
  183. repo = MultiDemandPoolDiRepository(session)
  184. mysql_keys = repo.list_keys_by_biz_dt(partition_date)
  185. to_insert_keys = odps_keys - mysql_keys
  186. to_delete_keys = mysql_keys - odps_keys
  187. to_update_keys = odps_keys & mysql_keys
  188. insert_rows = [odps_by_key[k] for k in to_insert_keys]
  189. update_rows = [odps_by_key[k] for k in to_update_keys]
  190. deleted = repo.delete_by_keys(partition_date, list(to_delete_keys))
  191. inserted = repo.bulk_insert(insert_rows)
  192. updated = repo.update_video_fields(partition_date, update_rows)
  193. logger.info(
  194. "Diff sync: odps=%d mysql_before=%d insert=%d delete=%d video_update=%d",
  195. len(odps_keys),
  196. len(mysql_keys),
  197. inserted,
  198. deleted,
  199. updated,
  200. )
  201. return {
  202. "fetched": len(raw_rows),
  203. "odps_unique": len(odps_keys),
  204. "mysql_before": len(mysql_keys),
  205. "inserted": inserted,
  206. "deleted": deleted,
  207. "video_updated": updated,
  208. "skipped_invalid": len(raw_rows) - len(mysql_rows),
  209. }
  210. def _to_float(value: Any) -> float | None:
  211. if value is None:
  212. return None
  213. try:
  214. return float(value)
  215. except (TypeError, ValueError):
  216. return None
  217. def _pick_better_real_metric(
  218. current: dict[str, Any] | None,
  219. candidate: dict[str, Any],
  220. ) -> dict[str, Any]:
  221. """相同特征值时保留 rov_diff、vov_diff 更高的一条。"""
  222. if current is None:
  223. return candidate
  224. current_key = (
  225. _to_float(current.get("rov_diff")) or 0.0,
  226. _to_float(current.get("vov_diff")) or 0.0,
  227. )
  228. candidate_key = (
  229. _to_float(candidate.get("rov_diff")) or 0.0,
  230. _to_float(candidate.get("vov_diff")) or 0.0,
  231. )
  232. return candidate if candidate_key > current_key else current
  233. def _build_real_metrics_by_feature(
  234. raw_rows: list[dict[str, Any]],
  235. ) -> dict[str, tuple[float | None, float | None]]:
  236. """按特征值去重,映射为 demand_name → (real_rov_7d, real_vov_7d)。"""
  237. best_by_feature: dict[str, dict[str, Any]] = {}
  238. for row in raw_rows:
  239. feature = row.get("特征值")
  240. if feature is None:
  241. continue
  242. feature_name = str(feature).strip()
  243. if not feature_name or feature_name == _GLOBAL_FEATURE_VALUE:
  244. continue
  245. best_by_feature[feature_name] = _pick_better_real_metric(
  246. best_by_feature.get(feature_name),
  247. row,
  248. )
  249. return {
  250. feature: (_to_float(row.get("rov_diff")), _to_float(row.get("vov_diff")))
  251. for feature, row in best_by_feature.items()
  252. }
  253. def enrich_real_rov_vov_7d(biz_dt: str) -> dict[str, Any]:
  254. """
  255. 从 ODPS 拉取执行日及往前 7 日的 rov_diff/vov_diff,按特征值匹配回填 MySQL。
  256. 相同特征值保留 rov_diff、vov_diff 更高的记录。
  257. """
  258. dt_right = biz_dt
  259. dt_left = (
  260. datetime.strptime(biz_dt, "%Y%m%d") - timedelta(days=_REAL_METRIC_LOOKBACK_DAYS)
  261. ).strftime("%Y%m%d")
  262. logger.info(
  263. "Enrich real rov/vov: biz_dt=%s range=%s~%s limit=%d",
  264. biz_dt,
  265. dt_left,
  266. dt_right,
  267. _REAL_METRIC_LIMIT,
  268. )
  269. odps = get_odps_client()
  270. raw_rows = odps.fetch_real_rov_vov_7d(
  271. dt_left=dt_left,
  272. dt_right=dt_right,
  273. limit=_REAL_METRIC_LIMIT,
  274. )
  275. metrics_by_name = _build_real_metrics_by_feature(raw_rows)
  276. with get_session() as session:
  277. updated = MultiDemandPoolDiRepository(session).update_real_metrics_by_demand_name(
  278. biz_dt,
  279. metrics_by_name,
  280. )
  281. result = {
  282. "dt_left": dt_left,
  283. "dt_right": dt_right,
  284. "odps_rows": len(raw_rows),
  285. "unique_features": len(metrics_by_name),
  286. "updated_rows": updated,
  287. }
  288. logger.info("Enrich real rov/vov completed: %s", result)
  289. return result
  290. def _calc_metric_stats(
  291. weights: list[float],
  292. places: int = 2,
  293. ) -> tuple[Decimal | None, int]:
  294. """
  295. 计算单策略 avg / count。
  296. 权重为 0 不参与平均值;全部为 0(或无有效权重)时 avg=null、count=0。
  297. """
  298. nonzero = [w for w in weights if w != 0]
  299. if not nonzero:
  300. return None, 0
  301. avg_val = sum(nonzero) / len(nonzero)
  302. q = Decimal("0." + "0" * places)
  303. return (
  304. Decimal(str(round(avg_val, places))).quantize(q),
  305. len(nonzero),
  306. )
  307. def _empty_metric_stats() -> dict[str, Decimal | int | None]:
  308. result: dict[str, Decimal | int | None] = {}
  309. for key in _ALL_METRIC_KEYS:
  310. result[f"{key}_avg"] = None
  311. result[f"{key}_count"] = 0
  312. return result
  313. def _build_stats_row(
  314. demand_category_id: int,
  315. demand_word_name: str,
  316. biz_dt: str,
  317. strategy_weights: list[tuple[str, float | None]],
  318. real_metrics: list[tuple[float | None, float | None]],
  319. ) -> dict[str, Any]:
  320. """按策略分组后汇总为 demand_popularity_stats 一行(含 rov_diff/vov_diff)。"""
  321. grouped: dict[str, list[float]] = {key: [] for key in _METRIC_KEYS}
  322. for strategy, weight in strategy_weights:
  323. metric = _STRATEGY_METRIC.get(strategy)
  324. if metric is None or weight is None or weight == 0:
  325. continue
  326. grouped[metric].append(float(weight))
  327. rov_values: list[float] = []
  328. vov_values: list[float] = []
  329. for rov, vov in real_metrics:
  330. if rov is not None:
  331. rov_values.append(float(rov))
  332. if vov is not None:
  333. vov_values.append(float(vov))
  334. row: dict[str, Any] = {
  335. "demand_category_id": demand_category_id,
  336. "demand_word_name": demand_word_name[:128],
  337. "biz_dt": biz_dt,
  338. **_empty_metric_stats(),
  339. }
  340. for key in _METRIC_KEYS:
  341. avg_val, count = _calc_metric_stats(
  342. grouped[key], places=_METRIC_DECIMAL_PLACES[key]
  343. )
  344. row[f"{key}_avg"] = avg_val
  345. row[f"{key}_count"] = count
  346. rov_avg, rov_count = _calc_metric_stats(
  347. rov_values, places=_METRIC_DECIMAL_PLACES["real_rov_7d"]
  348. )
  349. vov_avg, vov_count = _calc_metric_stats(
  350. vov_values, places=_METRIC_DECIMAL_PLACES["real_vov_7d"]
  351. )
  352. row["real_rov_7d_avg"] = rov_avg
  353. row["real_rov_7d_count"] = rov_count
  354. row["real_vov_7d_avg"] = vov_avg
  355. row["real_vov_7d_count"] = vov_count
  356. return row
  357. def compute_popularity_stats(biz_dt: str) -> dict[str, Any]:
  358. """
  359. 遍历 demand_belong_category 全部词,各自 LIKE 查询当天指定策略权重,
  360. 并汇总 rov_diff/vov_diff,写入 demand_popularity_stats(avg/count)。
  361. Args:
  362. biz_dt: 业务日期 (YYYYMMDD)
  363. """
  364. with get_session() as session:
  365. categories = DemandBelongCategoryRepository(session).list_active_id_name()
  366. if not categories:
  367. logger.info("Popularity stats: no demand_belong_category rows, skip")
  368. return {"words": 0, "upserted": 0}
  369. logger.info("Popularity stats: processing %d words for biz_dt=%s", len(categories), biz_dt)
  370. rows: list[dict[str, Any]] = []
  371. upserted = 0
  372. with get_session() as session:
  373. pool_repo = MultiDemandPoolDiRepository(session)
  374. stats_repo = DemandPopularityStatsRepository(session)
  375. for idx, (category_id, name) in enumerate(categories, start=1):
  376. matches = pool_repo.list_weights_by_name_like(
  377. biz_dt, name, _TARGET_STRATEGIES
  378. )
  379. real_matches = pool_repo.list_real_metrics_by_name_like(biz_dt, name)
  380. rows.append(
  381. _build_stats_row(category_id, name, biz_dt, matches, real_matches)
  382. )
  383. if len(rows) >= _STATS_UPSERT_BATCH:
  384. upserted += stats_repo.upsert_rows(rows)
  385. logger.info(
  386. "Popularity stats progress: %d/%d words, batch upserted",
  387. idx,
  388. len(categories),
  389. )
  390. rows = []
  391. if rows:
  392. upserted += stats_repo.upsert_rows(rows)
  393. result = {"words": len(categories), "upserted": upserted}
  394. logger.info("Popularity stats completed: %s", result)
  395. return result
  396. def _sync_pool_rows(partition_date: str) -> dict[str, Any]:
  397. """同步当天需求池主数据;供完整任务按独立阶段捕获异常。"""
  398. odps = get_odps_client()
  399. odps_count = odps.count_multi_demand_pool(partition_date)
  400. with get_session() as session:
  401. mysql_count = MultiDemandPoolDiRepository(session).count_by_biz_dt(partition_date)
  402. logger.info("Count check: odps=%d mysql=%d", odps_count, mysql_count)
  403. if odps_count == mysql_count:
  404. logger.info("Same count, skip ODPS data sync")
  405. return {
  406. "skipped_same_count": True,
  407. "odps_count": odps_count,
  408. "mysql_count": mysql_count,
  409. "fetched": 0,
  410. "inserted": 0,
  411. "deleted": 0,
  412. }
  413. return {
  414. "skipped_same_count": False,
  415. "odps_count": odps_count,
  416. "mysql_count": mysql_count,
  417. **_sync_diff(partition_date),
  418. }