| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597 |
- """
- Demand pool ODPS → MySQL sync stages used by the durable pipeline.
- Stages owned by this module:
- 1. source row sync (`_sync_pool_rows`)
- 2. demand word classification (`_classify_words`)
- 3. real rov/vov enrichment (`enrich_real_rov_vov_7d`)
- 4. popularity stats (`compute_popularity_stats`)
- Related pipeline stages live in sibling modules (belong_rel / tree_weight / videos).
- """
- from __future__ import annotations
- import hashlib
- import json
- import logging
- from concurrent.futures import ThreadPoolExecutor
- from datetime import datetime, timedelta
- from decimal import Decimal
- from typing import Any
- from supply_infra.category_match import (
- CategoryMatchClient,
- get_category_match_client,
- )
- from supply_infra.config import get_infra_settings
- from supply_infra.db.repositories.demand_belong_category_repo import (
- DemandBelongCategoryRepository,
- )
- from supply_infra.db.repositories.demand_belong_pool_rel_repo import (
- DemandBelongPoolRelRepository,
- )
- from supply_infra.db.repositories.demand_popularity_stats_repo import (
- DemandPopularityStatsRepository,
- )
- from supply_infra.db.repositories.global_tree_category_repo import (
- GlobalTreeCategoryRepository,
- )
- from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
- from supply_infra.db.session import get_session
- from supply_infra.odps.client import get_odps_client
- logger = logging.getLogger(__name__)
- _STATS_UPSERT_BATCH = 200
- _REAL_METRIC_LIMIT = 1000
- _REAL_METRIC_LOOKBACK_DAYS = 7
- _GLOBAL_FEATURE_VALUE = "全局SUM"
- _VIDEO_LIST_LIMIT = 10
- # 策略名 → 统计字段前缀;去年同期阳历/阴历合并为 plat_ly_pop
- _STRATEGY_METRIC: dict[str, str] = {
- "新热事件": "ext_pop",
- "逐月": "plat_sust_pop",
- "去年同期阳历": "plat_ly_pop",
- "去年同期阴历": "plat_ly_pop",
- "当下供需gap": "recent_pop",
- }
- _TARGET_STRATEGIES = list(_STRATEGY_METRIC.keys())
- _METRIC_KEYS = ("ext_pop", "plat_sust_pop", "plat_ly_pop", "recent_pop")
- _REAL_METRIC_KEYS = ("real_rov_7d", "real_vov_7d")
- _ALL_METRIC_KEYS = _METRIC_KEYS + _REAL_METRIC_KEYS
- _METRIC_DECIMAL_PLACES: dict[str, int] = {
- "ext_pop": 2,
- "plat_sust_pop": 2,
- "plat_ly_pop": 2,
- "recent_pop": 2,
- "real_rov_7d": 4,
- "real_vov_7d": 4,
- }
- RowKey = tuple[str, str]
- def _row_key(row: dict[str, Any]) -> RowKey:
- return (row["strategy"], row["demand_id"])
- _SOURCE_COMPARE_FIELDS = (
- "strategy",
- "demand_id",
- "demand_name",
- "weight",
- "type",
- "video_count",
- "video_list",
- "extend",
- "biz_dt",
- )
- def _source_signature(row: dict[str, Any]) -> tuple[Any, ...]:
- """生成稳定的源字段比较值;排除数据库 id 和下游回填字段。"""
- return tuple(row.get(field) for field in _SOURCE_COMPARE_FIELDS)
- def _source_snapshot_hash(rows: list[dict[str, Any]]) -> str:
- """生成与行顺序无关的内容哈希,供运行审计和水位比较。"""
- canonical = [
- {field: row.get(field) for field in _SOURCE_COMPARE_FIELDS}
- for row in sorted(rows, key=_row_key)
- ]
- payload = json.dumps(
- canonical,
- ensure_ascii=False,
- sort_keys=True,
- separators=(",", ":"),
- default=str,
- ).encode("utf-8")
- return hashlib.sha256(payload).hexdigest()
- def _normalize_video_list(raw: Any) -> tuple[str | None, int]:
- """取前 N 个 video_id,返回 (JSON 文本, count),二者保持一致。"""
- if raw is None:
- return None, 0
- items: list[Any]
- if isinstance(raw, str):
- text = raw.strip()
- if not text:
- return None, 0
- try:
- parsed = json.loads(text)
- items = list(parsed) if isinstance(parsed, list) else [text]
- except json.JSONDecodeError:
- items = [part.strip() for part in text.split(",") if part.strip()]
- elif isinstance(raw, (list, tuple)):
- items = list(raw)
- else:
- try:
- items = list(raw)
- except TypeError:
- return None, 0
- truncated = [
- str(v).strip()
- for v in items[:_VIDEO_LIST_LIMIT]
- if v is not None and str(v).strip()
- ]
- if not truncated:
- return None, 0
- return json.dumps(truncated, ensure_ascii=False), len(truncated)
- def _normalize_weight(raw: Any) -> float | None:
- """weight 为 0 或无效时视为无分数,存 null。"""
- if raw is None:
- return None
- try:
- value = float(raw)
- except (TypeError, ValueError):
- return None
- return None if value == 0 else value
- def _to_mysql_rows(raw_rows: list[dict[str, Any]], biz_dt: str) -> list[dict[str, Any]]:
- """转换为 MySQL 行,按 (strategy, demand_id) 去重(保留最后一条)。"""
- by_key: dict[RowKey, dict[str, Any]] = {}
- for row in raw_rows:
- strategy = row.get("strategy")
- demand_id = row.get("demand_id")
- demand_name = row.get("demand_name")
- if strategy is None or demand_id is None or demand_name is None:
- logger.warning("Skip row with missing required fields: %s", row)
- continue
- video_list, video_count = _normalize_video_list(row.get("video_list"))
- mapped = {
- "strategy": str(strategy),
- "demand_id": str(demand_id),
- "demand_name": str(demand_name),
- "weight": _normalize_weight(row.get("weight")),
- "type": str(row["type"]) if row.get("type") is not None else None,
- "video_count": video_count,
- "video_list": video_list,
- "extend": str(row["extend"]) if row.get("extend") is not None else None,
- "biz_dt": biz_dt,
- }
- by_key[_row_key(mapped)] = mapped
- return list(by_key.values())
- def _build_word_set(demand_names: list[str]) -> set[str]:
- """demand_name 按空格分词,全部落入同一个 set。"""
- words: set[str] = set()
- for name in demand_names:
- for token in str(name).split():
- word = token.strip()
- if word:
- words.add(word)
- return words
- def _classify_words(
- biz_dt: str,
- *,
- client: CategoryMatchClient | None = None,
- workers: int | None = None,
- ) -> dict:
- """逐词调用分类路径接口,将 stable_id 映射到本地分类后写入。"""
- with get_session() as session:
- demand_names = MultiDemandPoolDiRepository(
- session
- ).list_demand_names_by_biz_dt(biz_dt)
- word_set = _build_word_set(demand_names)
- existing = DemandBelongCategoryRepository(session).get_existing_names(word_set)
- pending = word_set - existing
- source_id_to_mysql_id = GlobalTreeCategoryRepository(
- session
- ).get_active_source_id_map()
- word_list = sorted(pending)
- matcher = client or get_category_match_client()
- worker_count = min(
- max(1, workers or get_infra_settings().category_match_workers),
- max(1, len(word_list)),
- )
- logger.info(
- "Category match prepare: demand_names=%d words=%d existing=%d "
- "pending=%d workers=%d",
- len(demand_names),
- len(word_set),
- len(existing),
- len(pending),
- worker_count,
- )
- rows: list[dict[str, Any]] = []
- unmatched_terms: list[str] = []
- failed_items: list[dict[str, Any]] = []
- def match_term(item: tuple[int, str]) -> tuple[str, Any, Exception | None]:
- idx, term = item
- logger.info("Matching category %d/%d: %s", idx, len(word_list), term)
- try:
- return term, matcher.match_one(term, description=""), None
- except Exception as exc:
- logger.exception(
- "Category match failed; continue remaining items: "
- "biz_dt=%s item=%s/%s term=%s",
- biz_dt,
- idx,
- len(word_list),
- term,
- )
- return term, None, exc
- indexed_terms = list(enumerate(word_list, start=1))
- with ThreadPoolExecutor(
- max_workers=worker_count,
- thread_name_prefix="category-match",
- ) as executor:
- outcomes = list(executor.map(match_term, indexed_terms))
- for term, match, error_value in outcomes:
- if error_value is not None:
- failed_items.append(
- {
- "term": term,
- "error": str(error_value),
- }
- )
- continue
- if match is None:
- unmatched_terms.append(term)
- continue
- category_id = source_id_to_mysql_id.get(match.stable_id)
- if category_id is None:
- error = (
- f"matched stable_id={match.stable_id} is absent from "
- "global_tree_category"
- )
- logger.error("Category match cannot be persisted: term=%s %s", term, error)
- failed_items.append({"term": term, "error": error})
- continue
- rows.append(
- {
- "name": term,
- "category_id": category_id,
- "reason": match.reason,
- "is_delete": 0,
- }
- )
- with get_session() as session:
- persisted = DemandBelongCategoryRepository(
- session
- ).upsert_category_matches(rows)
- result = {
- "success": not failed_items,
- "demand_names": len(demand_names),
- "words": len(word_set),
- "existing_filtered": len(existing),
- "pending": len(pending),
- "api_calls": len(word_list),
- "matched": len(rows),
- "persisted": persisted,
- "unmatched": len(unmatched_terms),
- "unmatched_terms": unmatched_terms,
- "failed_items": failed_items,
- }
- if failed_items:
- result["error_code"] = "category_match_failed_items"
- result["error"] = f"{len(failed_items)} category match item(s) failed"
- return result
- def _sync_diff(partition_date: str) -> dict[str, Any]:
- """按主键和完整源内容同步;行数相同也会识别修订。"""
- odps = get_odps_client()
- raw_rows = odps.fetch_multi_demand_pool(partition_date)
- mysql_rows = _to_mysql_rows(raw_rows, partition_date)
- odps_by_key = {_row_key(r): r for r in mysql_rows}
- odps_keys = set(odps_by_key)
- with get_session() as session:
- repo = MultiDemandPoolDiRepository(session)
- existing_rows = repo.list_source_rows_by_biz_dt(partition_date)
- mysql_by_key = {_row_key(row): row for row in existing_rows}
- mysql_keys = set(mysql_by_key)
- to_insert_keys = odps_keys - mysql_keys
- to_delete_keys = mysql_keys - odps_keys
- shared_keys = odps_keys & mysql_keys
- to_update_keys = {
- key
- for key in shared_keys
- if _source_signature(odps_by_key[key])
- != _source_signature(mysql_by_key[key])
- }
- insert_rows = [odps_by_key[k] for k in to_insert_keys]
- update_rows = [odps_by_key[k] for k in to_update_keys]
- deleted_pool_ids = [mysql_by_key[key]["id"] for key in to_delete_keys]
- deleted_relations = DemandBelongPoolRelRepository(session).delete_by_pool_ids(
- deleted_pool_ids
- )
- deleted = repo.delete_by_keys(partition_date, list(to_delete_keys))
- inserted = repo.bulk_insert(insert_rows)
- updated = (
- repo.update_source_fields(partition_date, update_rows)
- if update_rows
- else 0
- )
- logger.info(
- "Diff sync: odps=%d mysql_before=%d insert=%d delete=%d update=%d unchanged=%d",
- len(odps_keys),
- len(mysql_keys),
- inserted,
- deleted,
- updated,
- len(shared_keys) - len(to_update_keys),
- )
- return {
- "fetched": len(raw_rows),
- "odps_unique": len(odps_keys),
- "mysql_before": len(mysql_keys),
- "inserted": inserted,
- "deleted": deleted,
- "relations_deleted": deleted_relations,
- "updated": updated,
- "unchanged": len(shared_keys) - len(to_update_keys),
- "source_snapshot_hash": _source_snapshot_hash(mysql_rows),
- "skipped_invalid": len(raw_rows) - len(mysql_rows),
- }
- def _to_float(value: Any) -> float | None:
- if value is None:
- return None
- try:
- return float(value)
- except (TypeError, ValueError):
- return None
- def _pick_better_real_metric(
- current: dict[str, Any] | None,
- candidate: dict[str, Any],
- ) -> dict[str, Any]:
- """相同特征值时保留 rov_diff、vov_diff 更高的一条。"""
- if current is None:
- return candidate
- current_key = (
- _to_float(current.get("rov_diff")) or 0.0,
- _to_float(current.get("vov_diff")) or 0.0,
- )
- candidate_key = (
- _to_float(candidate.get("rov_diff")) or 0.0,
- _to_float(candidate.get("vov_diff")) or 0.0,
- )
- return candidate if candidate_key > current_key else current
- def _build_real_metrics_by_feature(
- raw_rows: list[dict[str, Any]],
- ) -> dict[str, tuple[float | None, float | None]]:
- """按特征值去重,映射为 demand_name → (real_rov_7d, real_vov_7d)。"""
- best_by_feature: dict[str, dict[str, Any]] = {}
- for row in raw_rows:
- feature = row.get("特征值")
- if feature is None:
- continue
- feature_name = str(feature).strip()
- if not feature_name or feature_name == _GLOBAL_FEATURE_VALUE:
- continue
- best_by_feature[feature_name] = _pick_better_real_metric(
- best_by_feature.get(feature_name),
- row,
- )
- return {
- feature: (_to_float(row.get("rov_diff")), _to_float(row.get("vov_diff")))
- for feature, row in best_by_feature.items()
- }
- def enrich_real_rov_vov_7d(biz_dt: str) -> dict[str, Any]:
- """
- 从 ODPS 拉取执行日及往前 7 日的 rov_diff/vov_diff,按特征值匹配回填 MySQL。
- 相同特征值保留 rov_diff、vov_diff 更高的记录。
- """
- dt_right = biz_dt
- dt_left = (
- datetime.strptime(biz_dt, "%Y%m%d") - timedelta(days=_REAL_METRIC_LOOKBACK_DAYS)
- ).strftime("%Y%m%d")
- logger.info(
- "Enrich real rov/vov: biz_dt=%s range=%s~%s limit=%d",
- biz_dt,
- dt_left,
- dt_right,
- _REAL_METRIC_LIMIT,
- )
- odps = get_odps_client()
- raw_rows = odps.fetch_real_rov_vov_7d(
- dt_left=dt_left,
- dt_right=dt_right,
- limit=_REAL_METRIC_LIMIT,
- )
- metrics_by_name = _build_real_metrics_by_feature(raw_rows)
- with get_session() as session:
- updated = MultiDemandPoolDiRepository(session).update_real_metrics_by_demand_name(
- biz_dt,
- metrics_by_name,
- )
- result = {
- "dt_left": dt_left,
- "dt_right": dt_right,
- "odps_rows": len(raw_rows),
- "unique_features": len(metrics_by_name),
- "updated_rows": updated,
- }
- logger.info("Enrich real rov/vov completed: %s", result)
- return result
- def _calc_metric_stats(
- weights: list[float],
- places: int = 2,
- ) -> tuple[Decimal | None, int]:
- """
- 计算单策略 avg / count。
- 权重为 0 不参与平均值;全部为 0(或无有效权重)时 avg=null、count=0。
- """
- nonzero = [w for w in weights if w != 0]
- if not nonzero:
- return None, 0
- avg_val = sum(nonzero) / len(nonzero)
- q = Decimal("0." + "0" * places)
- return (
- Decimal(str(round(avg_val, places))).quantize(q),
- len(nonzero),
- )
- def _empty_metric_stats() -> dict[str, Decimal | int | None]:
- result: dict[str, Decimal | int | None] = {}
- for key in _ALL_METRIC_KEYS:
- result[f"{key}_avg"] = None
- result[f"{key}_count"] = 0
- return result
- def _build_stats_row(
- demand_category_id: int,
- demand_word_name: str,
- biz_dt: str,
- strategy_weights: list[tuple[str, float | None]],
- real_metrics: list[tuple[float | None, float | None]],
- ) -> dict[str, Any]:
- """按策略分组后汇总为 demand_popularity_stats 一行(含 rov_diff/vov_diff)。"""
- grouped: dict[str, list[float]] = {key: [] for key in _METRIC_KEYS}
- for strategy, weight in strategy_weights:
- metric = _STRATEGY_METRIC.get(strategy)
- if metric is None or weight is None or weight == 0:
- continue
- grouped[metric].append(float(weight))
- rov_values: list[float] = []
- vov_values: list[float] = []
- for rov, vov in real_metrics:
- if rov is not None:
- rov_values.append(float(rov))
- if vov is not None:
- vov_values.append(float(vov))
- row: dict[str, Any] = {
- "demand_category_id": demand_category_id,
- "demand_word_name": demand_word_name[:128],
- "biz_dt": biz_dt,
- **_empty_metric_stats(),
- }
- for key in _METRIC_KEYS:
- avg_val, count = _calc_metric_stats(
- grouped[key], places=_METRIC_DECIMAL_PLACES[key]
- )
- row[f"{key}_avg"] = avg_val
- row[f"{key}_count"] = count
- rov_avg, rov_count = _calc_metric_stats(
- rov_values, places=_METRIC_DECIMAL_PLACES["real_rov_7d"]
- )
- vov_avg, vov_count = _calc_metric_stats(
- vov_values, places=_METRIC_DECIMAL_PLACES["real_vov_7d"]
- )
- row["real_rov_7d_avg"] = rov_avg
- row["real_rov_7d_count"] = rov_count
- row["real_vov_7d_avg"] = vov_avg
- row["real_vov_7d_count"] = vov_count
- return row
- def compute_popularity_stats(biz_dt: str) -> dict[str, Any]:
- """
- 遍历 demand_belong_category 全部词,各自 LIKE 查询当天指定策略权重,
- 并汇总 rov_diff/vov_diff,写入 demand_popularity_stats(avg/count)。
- Args:
- biz_dt: 业务日期 (YYYYMMDD)
- """
- with get_session() as session:
- categories = DemandBelongCategoryRepository(session).list_active_id_name()
- if not categories:
- logger.info("Popularity stats: no demand_belong_category rows, skip")
- return {"words": 0, "upserted": 0}
- logger.info("Popularity stats: processing %d words for biz_dt=%s", len(categories), biz_dt)
- rows: list[dict[str, Any]] = []
- upserted = 0
- with get_session() as session:
- pool_repo = MultiDemandPoolDiRepository(session)
- stats_repo = DemandPopularityStatsRepository(session)
- for idx, (category_id, name) in enumerate(categories, start=1):
- matches = pool_repo.list_weights_by_name_like(
- biz_dt, name, _TARGET_STRATEGIES
- )
- real_matches = pool_repo.list_real_metrics_by_name_like(biz_dt, name)
- rows.append(
- _build_stats_row(category_id, name, biz_dt, matches, real_matches)
- )
- if len(rows) >= _STATS_UPSERT_BATCH:
- upserted += stats_repo.upsert_rows(rows)
- logger.info(
- "Popularity stats progress: %d/%d words, batch upserted",
- idx,
- len(categories),
- )
- rows = []
- if rows:
- upserted += stats_repo.upsert_rows(rows)
- result = {"words": len(categories), "upserted": upserted}
- logger.info("Popularity stats completed: %s", result)
- return result
- def _sync_pool_rows(partition_date: str) -> dict[str, Any]:
- """同步当天需求池主数据;内容哈希差分替代不可靠的行数门禁。"""
- return _sync_diff(partition_date)
|