material_recall.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772
  1. """创意素材召回接口适配层(vector 服务)。
  2. 业务模型(用户 2026-06-08 确认):
  3. 对一个承接视频(LandingVideo)执行 3 种策略召回素材,合并去重,按 score 取 top N。
  4. 接口:POST https://tp-open.piaoquantv.com/contentPlatform/videoVector/recallTest/matchByText
  5. 鉴权:复用 contentPlatform 动态登录 token
  6. 本质(用户 2026-06-08 确认):**多路召回 → 排序 → 去重**
  7. 实测确认素材库的 4 个有效 configCode(VIDEO_TITLE 和 ALL 对 MATERIAL 模态返回 0):
  8. - VIDEO_TOPIC (选题向量)
  9. - VIDEO_KEYPOINT (关键点向量)
  10. - VIDEO_INSPIRATION(灵感点向量)
  11. - VIDEO_PURPOSE (目的点向量)
  12. 策略:对一个承接视频,选合适的 queryText,**串行调 4 个 configCode**,合并按 materialId 去重(取 max score),按 score 降序返回 top N。
  13. queryText 选择优先级(选第一个非空且非占位符 "-"):
  14. 1. standard_element(标准化元素,实测效果最好)
  15. 2. demand_content_topic(选题,如果非 "-")
  16. 3. title(承接视频标题,兜底)
  17. """
  18. import logging
  19. import os
  20. from concurrent.futures import ThreadPoolExecutor, as_completed
  21. from dataclasses import dataclass, field
  22. from typing import Iterable, List, Optional
  23. import httpx
  24. from tools.video_recall import (
  25. LandingVideo,
  26. _build_auth_headers,
  27. _is_not_login_error,
  28. get_piaoquantv_token,
  29. refresh_piaoquantv_token,
  30. )
  31. logger = logging.getLogger(__name__)
  32. VECTOR_BASE = os.getenv(
  33. "VECTOR_BASE_URL",
  34. "https://tp-open.piaoquantv.com/contentPlatform/videoVector",
  35. )
  36. VECTOR_MATCH_BY_TEXT = f"{VECTOR_BASE}/recallTest/matchByText"
  37. VECTOR_BATCH_BY_TEXT = f"{VECTOR_BASE}/recallTest/batchByText"
  38. VECTOR_ALL_CONFIG_CODES = f"{VECTOR_BASE}/videoSearch/getAllConfigCodes"
  39. def _request_vector(
  40. method: str,
  41. url: str,
  42. *,
  43. json_body: Optional[dict] = None,
  44. timeout: int = 30,
  45. ) -> dict:
  46. """调用 growth-manager vector 转发接口,token 失效时刷新并重试一次。"""
  47. token = get_piaoquantv_token()
  48. def _do_request(current_token: str):
  49. return httpx.request(
  50. method,
  51. url,
  52. json=json_body,
  53. headers=_build_auth_headers(current_token),
  54. timeout=timeout,
  55. )
  56. resp = _do_request(token)
  57. if resp.status_code == 401:
  58. logger.warning("[material_recall] vector 转发接口返回 401,刷新 token 并重试")
  59. token = refresh_piaoquantv_token()
  60. resp = _do_request(token)
  61. resp.raise_for_status()
  62. data = resp.json()
  63. if _is_not_login_error(data):
  64. logger.warning("[material_recall] vector 转发接口返回 Not_LOGIN(1000),刷新 token 并重试")
  65. token = refresh_piaoquantv_token()
  66. resp = _do_request(token)
  67. resp.raise_for_status()
  68. data = resp.json()
  69. return data
  70. def fetch_all_config_codes() -> dict[str, str]:
  71. """动态获取 vector 服务支持的全部 configCode → 中文名。
  72. 返回 {configCode: 中文名}。
  73. 召回前先调一次,避免硬编码列表过时。
  74. """
  75. data = _request_vector("GET", VECTOR_ALL_CONFIG_CODES, timeout=15)
  76. if data.get("code") not in (0, 200):
  77. raise RuntimeError(
  78. f"getAllConfigCodes 失败:code={data.get('code')} msg={data.get('msg')}"
  79. )
  80. return data.get("data") or {}
  81. # 字段维度策略(2026-06-08 用户最终确认)
  82. # - 维度 1 选题: queryText=demand_content_topic, configCode=VIDEO_TOPIC
  83. # - 维度 2 标准化元素: queryText=standard_element, configCode 按 point_type **唯一对应**:
  84. # - "灵感点" → INSPIRATION_SUBSTANCE
  85. # - "关键点" → KEYPOINT_SUBSTANCE
  86. # - "目的点" → PURPOSE_SUBSTANCE
  87. # - 其他/空 → 跳过(没办法确定走哪一路,no-guessing)
  88. # - 维度 3 标题: 不做(VIDEO_TITLE 对 MATERIAL 实测返回 0)
  89. POINT_TYPE_TO_SUBSTANCE = {
  90. "灵感点": "INSPIRATION_SUBSTANCE",
  91. "关键点": "KEYPOINT_SUBSTANCE",
  92. "目的点": "PURPOSE_SUBSTANCE",
  93. }
  94. POINT_TYPE_TO_VIDEO_CONFIG = {
  95. "灵感点": "VIDEO_INSPIRATION",
  96. "关键点": "VIDEO_KEYPOINT",
  97. "目的点": "VIDEO_PURPOSE",
  98. }
  99. SUBSTANCE_TO_VIDEO_CONFIG = {
  100. "INSPIRATION_SUBSTANCE": "VIDEO_INSPIRATION",
  101. "KEYPOINT_SUBSTANCE": "VIDEO_KEYPOINT",
  102. "PURPOSE_SUBSTANCE": "VIDEO_PURPOSE",
  103. }
  104. ODPS_FEATURE_TO_CONFIG = {
  105. ("解构选题", ""): "VIDEO_TOPIC",
  106. ("实质", "灵感点"): "INSPIRATION_SUBSTANCE",
  107. ("实质", "关键点"): "KEYPOINT_SUBSTANCE",
  108. ("实质", "目的点"): "PURPOSE_SUBSTANCE",
  109. }
  110. # fallback(动态接口失败时用)
  111. MATERIAL_EFFECTIVE_CONFIG_CODES = ["VIDEO_TOPIC"] + list(POINT_TYPE_TO_SUBSTANCE.values())
  112. # 占位符值视为无效(piaoquantv 数据里 demandContentTopic 大量为 "-")
  113. PLACEHOLDER_VALUES = {"", "-", None}
  114. # 单 configCode 召回 top N(合并前)
  115. DEFAULT_PER_CC_TOP_N = 100
  116. # 最终合并去重后返回 top N
  117. DEFAULT_FINAL_TOP_N = 20
  118. # Cover URL 黑名单 — 已知尺寸/比例不符合腾讯创意要求的来源
  119. # (2026-06-09 用户决策:踩坑驱动渐进收紧,踩到新模式再加)
  120. # - "auto_reply_cards_cover" :自动回复卡片业务的 cover,实测 reject code 1801159
  121. EXCLUDED_COVER_URL_PATTERNS = (
  122. "auto_reply_cards_cover",
  123. )
  124. @dataclass
  125. class RecallQuery:
  126. """一条由 ODPS 特征生成的素材召回查询。"""
  127. query_text: str
  128. config_code: str
  129. element_dimension: str
  130. point_type: str
  131. standard_element: str
  132. contribution_score: float = 0.0
  133. dt: str = ""
  134. @property
  135. def strategy_name(self) -> str:
  136. """返回召回策略名称(解构选题 或 元素维度-点类型)。"""
  137. if self.element_dimension == "解构选题":
  138. return "解构选题"
  139. return f"{self.element_dimension}-{self.point_type}"
  140. def to_hit(self) -> dict:
  141. """把召回 query 转成命中记录 dict(写入素材的 recall_hit_queries)。"""
  142. return {
  143. "strategy": self.strategy_name,
  144. "element_dimension": self.element_dimension,
  145. "point_type": self.point_type,
  146. "standard_element": self.standard_element,
  147. "query_text": self.query_text,
  148. "config_code": self.config_code,
  149. "contribution_score": self.contribution_score,
  150. "dt": self.dt,
  151. }
  152. @dataclass
  153. class Material:
  154. """召回的创意素材。
  155. 2026-06-10 升级:从 batchByText 接口接收质量数据(cost/ctr/cvr/roi/impressions 等)。
  156. """
  157. material_id: str # 素材原始 ID
  158. score: float # 服务端综合分(wCtr=1 配置下 ≈ qualityScore,与 CTR 强相关)
  159. title: str = ""
  160. cover: str = ""
  161. video_url: str = ""
  162. # 投放质量数据(来自 materialDetail.quality)— 2026-06-10 升级新增
  163. cost: Optional[float] = None # 累计成本(元)
  164. ctr: Optional[float] = None # 点击率
  165. cvr: Optional[float] = None # 转化率
  166. roi: Optional[float] = None # ROI(收入/成本)
  167. impressions: Optional[int] = None # 累计曝光
  168. quality_score: Optional[float] = None # 服务端 qualityScore
  169. visit_uv_30d: Optional[int] = None # 外部素材配置窗口内访问UV之和(列名兼容历史)
  170. recall_strategy: str = ""
  171. recall_query_text: str = ""
  172. recall_config_code: str = ""
  173. recall_element_dimension: str = ""
  174. recall_point_type: str = ""
  175. recall_standard_element: str = ""
  176. recall_hit_queries: List[dict] = field(default_factory=list)
  177. # 原始 item dict(以备后用)
  178. raw: dict = field(default_factory=dict, repr=False)
  179. def _call_match_by_text(
  180. query_text: str,
  181. config_code: str,
  182. material_top_n: int = DEFAULT_PER_CC_TOP_N,
  183. source_labels: Optional[List[str]] = None,
  184. timeout: int = 30,
  185. ) -> List[dict]:
  186. """单次调 matchByText,只要 MATERIAL 模态,返回原始 items 列表。"""
  187. body = {
  188. "queryText": query_text,
  189. "configCode": config_code,
  190. "modalities": ["MATERIAL"],
  191. "videoTopN": 0,
  192. "articleTopN": 0,
  193. "materialTopN": material_top_n,
  194. "topN": material_top_n,
  195. "displayK": material_top_n,
  196. }
  197. if source_labels:
  198. body["sourceLabels"] = source_labels
  199. logger.info(
  200. "[material_recall] matchByText q=%r configCode=%s topN=%d",
  201. query_text[:40], config_code, material_top_n,
  202. )
  203. data = _request_vector(
  204. "POST",
  205. VECTOR_MATCH_BY_TEXT,
  206. json_body=body,
  207. timeout=timeout,
  208. )
  209. code = data.get("code")
  210. # CommonResponse 可能用 0 或 200 表示成功;以 data 字段为准
  211. if code not in (0, 200, "0", "200"):
  212. raise RuntimeError(
  213. f"vector matchByText 失败:code={code} msg={data.get('msg') or data.get('message')}"
  214. )
  215. payload = data.get("data") or {}
  216. items = payload.get("items") or []
  217. # 只留 MATERIAL 模态(防御性 — 万一返回混入)
  218. return [it for it in items if it.get("modality") == "MATERIAL"]
  219. def _pick_query_text(landing: LandingVideo) -> Optional[str]:
  220. """按优先级选 queryText:standard_element > demand_content_topic > title。
  221. 占位符 "-" / 空串视为无效。"""
  222. for cand in (landing.standard_element, landing.demand_content_topic, landing.title):
  223. if cand and cand not in PLACEHOLDER_VALUES:
  224. return cand
  225. return None
  226. def _pick_query_text_for_batch(landing: LandingVideo) -> Optional[str]:
  227. """选 queryText:standard_element > demand_content_topic > title。(保留兼容)"""
  228. for cand in (landing.standard_element, landing.demand_content_topic, landing.title):
  229. if cand and cand not in PLACEHOLDER_VALUES:
  230. return cand
  231. return None
  232. def _pick_query_strategies_for_batch(landing: LandingVideo) -> List[tuple]:
  233. """返回 (queryText, configCode, strategy_name) 策略候选(2026-06-10 用户最终确认)。
  234. 只用 2 个维度:
  235. 维度 1 - 标准化元素:queryText=standard_element, configCode 按 point_type 唯一对应
  236. - "灵感点" → INSPIRATION_SUBSTANCE
  237. - "关键点" → KEYPOINT_SUBSTANCE
  238. - "目的点" → PURPOSE_SUBSTANCE
  239. 维度 2 - 选题:queryText=demand_content_topic, configCode=VIDEO_TOPIC
  240. prepare 阶段:维度 1 失败 → 试维度 2 → 仍失败 → 换 landing。
  241. """
  242. strategies = []
  243. seen = set()
  244. for feature in landing.raw.get("element_features") or []:
  245. if not isinstance(feature, dict):
  246. continue
  247. standard_element = (feature.get("standard_element") or "").strip()
  248. point_type = (feature.get("point_type") or "").strip()
  249. if standard_element in PLACEHOLDER_VALUES:
  250. continue
  251. cc = POINT_TYPE_TO_SUBSTANCE.get(point_type)
  252. if not cc:
  253. continue
  254. key = (standard_element, cc)
  255. if key in seen:
  256. continue
  257. seen.add(key)
  258. strategies.append((standard_element, cc, f"标准化元素-{point_type}"))
  259. # 维度 1: 标准化元素(优先)
  260. if landing.standard_element and landing.standard_element not in PLACEHOLDER_VALUES:
  261. cc = POINT_TYPE_TO_SUBSTANCE.get(landing.point_type)
  262. if cc and (landing.standard_element, cc) not in seen:
  263. strategies.append((landing.standard_element, cc, f"标准化元素-{landing.point_type}"))
  264. # 维度 2: 选题
  265. if landing.demand_content_topic and landing.demand_content_topic not in PLACEHOLDER_VALUES:
  266. strategies.append((landing.demand_content_topic, "VIDEO_TOPIC", "选题"))
  267. return strategies
  268. def _fallback_config_codes_for_strategy(config_code: str, landing: LandingVideo) -> List[str]:
  269. """batchByText 无结果时给 matchByText 的兼容 configCode。
  270. 2026-06-30 实测:batchByText 对 MATERIAL 返回 0 时,旧 matchByText 仍可在
  271. VIDEO_KEYPOINT/VIDEO_INSPIRATION 等历史向量字段命中素材。
  272. """
  273. out = [config_code]
  274. video_cc = SUBSTANCE_TO_VIDEO_CONFIG.get(config_code) or POINT_TYPE_TO_VIDEO_CONFIG.get(landing.point_type)
  275. if video_cc and video_cc not in out:
  276. out.append(video_cc)
  277. if config_code != "VIDEO_TOPIC" and "VIDEO_TOPIC" not in out:
  278. out.append("VIDEO_TOPIC")
  279. return out
  280. def _call_batch_by_text(
  281. query_text: str,
  282. config_codes: List[str],
  283. display_k: int,
  284. days: int,
  285. sim_threshold: float,
  286. alpha: float,
  287. w_ctr: float, w_cvr: float, w_roi: float,
  288. w_open_rate: float, w_fission_rate: float,
  289. deconstruct_boost: float,
  290. source_labels: List[str],
  291. modalities: List[str] = None,
  292. timeout: int = 30,
  293. ) -> List[dict]:
  294. """调用 batchByText 接口(2026-06-10 升级).
  295. 服务端单次 embedding + 多 configCode 并行 ANN + 跨模态过滤 + ranking 加权 + 去重。
  296. """
  297. body = {
  298. "queryText": query_text,
  299. "configCodes": config_codes,
  300. "displayK": display_k,
  301. "modalities": modalities or ["MATERIAL"],
  302. "sourceLabels": source_labels,
  303. "days": days,
  304. "ranking": {
  305. "simThreshold": sim_threshold,
  306. "alpha": alpha,
  307. "wCtr": w_ctr, "wCvr": w_cvr, "wRoi": w_roi,
  308. "wOpenRate": w_open_rate, "wFissionRate": w_fission_rate,
  309. "deconstructBoost": deconstruct_boost,
  310. },
  311. }
  312. logger.info(
  313. "[material_recall] batchByText q=%r configCodes=%d displayK=%d simT=%.2f wCtr=%.2f",
  314. query_text[:40], len(config_codes), display_k, sim_threshold, w_ctr,
  315. )
  316. data = _request_vector(
  317. "POST",
  318. VECTOR_BATCH_BY_TEXT,
  319. json_body=body,
  320. timeout=timeout,
  321. )
  322. if data.get("code") not in (0, 200, "0", "200"):
  323. raise RuntimeError(
  324. f"batchByText 失败:code={data.get('code')} msg={data.get('msg') or data.get('message')}"
  325. )
  326. payload = data.get("data") or {}
  327. return payload.get("items") or []
  328. def _as_float(value, default: float = 0.0) -> float:
  329. """安全转 float,None 或非法值返回 default。"""
  330. try:
  331. if value is None:
  332. return default
  333. return float(value)
  334. except (TypeError, ValueError):
  335. return default
  336. def _as_int(value, default: int = 0) -> int:
  337. """安全转 int,None 或非法值返回 default。"""
  338. try:
  339. if value is None:
  340. return default
  341. return int(float(value))
  342. except (TypeError, ValueError):
  343. return default
  344. def _items_to_materials(
  345. items: List[dict],
  346. sim_threshold: float,
  347. *,
  348. apply_cover_blacklist: bool = True,
  349. min_cost: Optional[float] = None,
  350. apply_cost_filter: bool = True,
  351. ) -> tuple:
  352. """把召回 items 过滤 + 转 Material。
  353. 过滤条件:
  354. - modality=MATERIAL(防御性)
  355. - cover URL 不在黑名单
  356. - score >= sim_threshold
  357. - apply_cost_filter 且 min_cost is not None 时:历史成本(cost, 元) >= min_cost
  358. (严格,无成本数据也排除)。外部合作素材召回关闭 apply_cost_filter,
  359. 仅以访问 UV 门槛把关。
  360. CTR / impressions 只作为审批展示和兜底排序参考,不再作为硬筛。
  361. 返回 (materials, stats)。
  362. """
  363. out: List[Material] = []
  364. stats = {
  365. "blacklist": 0,
  366. "low_score": 0,
  367. "low_cost": 0,
  368. "low_imp": 0,
  369. "low_ctr": 0,
  370. }
  371. for it in items:
  372. if it.get("modality") != "MATERIAL":
  373. continue
  374. mid = it.get("materialId") or (str(it["id"]) if it.get("id") is not None else None)
  375. if not mid:
  376. continue
  377. cover = it.get("cover") or ""
  378. if apply_cover_blacklist and any(
  379. pattern in cover for pattern in EXCLUDED_COVER_URL_PATTERNS
  380. ):
  381. stats["blacklist"] += 1
  382. continue
  383. score = _as_float(it.get("score"))
  384. if score < sim_threshold:
  385. stats["low_score"] += 1
  386. continue
  387. md = it.get("materialDetail") or {}
  388. q = md.get("quality") or {}
  389. raw_cost = q.get("cost")
  390. cost = _as_float(raw_cost) if raw_cost is not None else None
  391. if apply_cost_filter and min_cost is not None and (cost is None or cost < min_cost):
  392. stats["low_cost"] += 1
  393. continue
  394. out.append(Material(
  395. material_id=str(mid),
  396. score=score,
  397. title=it.get("title") or "",
  398. cover=cover,
  399. video_url=it.get("videoUrl") or "",
  400. cost=cost,
  401. ctr=_as_float(q.get("ctr")) if q.get("ctr") is not None else None,
  402. cvr=_as_float(q.get("cvr")) if q.get("cvr") is not None else None,
  403. roi=_as_float(q.get("roi")) if q.get("roi") is not None else None,
  404. impressions=_as_int(q.get("impressions")) if q.get("impressions") is not None else None,
  405. quality_score=_as_float(q.get("qualityScore")) if q.get("qualityScore") is not None else None,
  406. raw=it,
  407. ))
  408. return out, stats
  409. def _sort_materials_by_policy(materials: List[Material]) -> List[Material]:
  410. """生产排序策略:先相关性准入,再按历史消耗倒序。"""
  411. return sorted(
  412. materials,
  413. key=lambda m: (
  414. m.cost is not None,
  415. m.cost or 0,
  416. m.roi or 0,
  417. m.impressions or 0,
  418. m.ctr or 0,
  419. m.quality_score or 0,
  420. m.score or 0,
  421. ),
  422. reverse=True,
  423. )
  424. def _material_rank(material: Material) -> tuple:
  425. """生成素材排序键(与 _sort_materials_by_policy 同序:消耗优先)。"""
  426. return (
  427. material.cost is not None,
  428. material.cost or 0,
  429. material.roi or 0,
  430. material.impressions or 0,
  431. material.ctr or 0,
  432. material.quality_score or 0,
  433. material.score or 0,
  434. )
  435. def _feature_attr(feature, name: str, default=""):
  436. """兼容 dict 和对象两种 feature,统一取属性值。"""
  437. if isinstance(feature, dict):
  438. return feature.get(name, default)
  439. return getattr(feature, name, default)
  440. def _build_recall_queries_from_features(
  441. element_features: Iterable,
  442. query_limit: int,
  443. ) -> List[RecallQuery]:
  444. """根据 ODPS 特征构建召回查询。
  445. 支持的维度:解构选题映射到 VIDEO_TOPIC;实质加灵感点、关键点或目的点映射
  446. 到对应的 *_SUBSTANCE。
  447. """
  448. queries: List[RecallQuery] = []
  449. seen = set()
  450. raw_features = list(element_features or [])
  451. sorted_features = sorted(
  452. raw_features,
  453. key=lambda f: (
  454. 0 if str(_feature_attr(f, "element_dimension") or "") == "解构选题" else 1,
  455. -float(_feature_attr(f, "contribution_score", 0) or 0),
  456. ),
  457. )
  458. for feature in sorted_features:
  459. element_dimension = str(_feature_attr(feature, "element_dimension") or "").strip()
  460. point_type = str(_feature_attr(feature, "point_type") or "").strip()
  461. standard_element = str(_feature_attr(feature, "standard_element") or "").strip()
  462. if standard_element in PLACEHOLDER_VALUES:
  463. continue
  464. config_code = ODPS_FEATURE_TO_CONFIG.get((element_dimension, point_type))
  465. if not config_code and element_dimension == "解构选题":
  466. config_code = ODPS_FEATURE_TO_CONFIG.get(("解构选题", ""))
  467. if not config_code:
  468. continue
  469. key = (standard_element, config_code)
  470. if key in seen:
  471. continue
  472. seen.add(key)
  473. queries.append(RecallQuery(
  474. query_text=standard_element,
  475. config_code=config_code,
  476. element_dimension=element_dimension,
  477. point_type=point_type,
  478. standard_element=standard_element,
  479. contribution_score=float(_feature_attr(feature, "contribution_score", 0) or 0),
  480. dt=str(_feature_attr(feature, "dt") or ""),
  481. ))
  482. if len(queries) >= query_limit:
  483. break
  484. return queries
  485. def _call_batch_for_recall_query(
  486. query: RecallQuery,
  487. *,
  488. display_k: int,
  489. days: int,
  490. sim_threshold: float,
  491. alpha: float,
  492. w_ctr: float,
  493. w_cvr: float,
  494. w_roi: float,
  495. w_open_rate: float,
  496. w_fission_rate: float,
  497. deconstruct_boost: float,
  498. source_labels: List[str],
  499. ) -> tuple[RecallQuery, List[dict]]:
  500. """对单个召回 query 调 batchByText,返回 (query, 原始 items)。供并行调用。"""
  501. items = _call_batch_by_text(
  502. query_text=query.query_text,
  503. config_codes=[query.config_code],
  504. display_k=display_k,
  505. days=days,
  506. sim_threshold=sim_threshold,
  507. alpha=alpha,
  508. w_ctr=w_ctr,
  509. w_cvr=w_cvr,
  510. w_roi=w_roi,
  511. w_open_rate=w_open_rate,
  512. w_fission_rate=w_fission_rate,
  513. deconstruct_boost=deconstruct_boost,
  514. source_labels=source_labels,
  515. modalities=["MATERIAL"],
  516. )
  517. return query, items
  518. def _merge_materials_by_policy(query_materials: List[tuple[RecallQuery, List[Material]]]) -> List[Material]:
  519. """多路召回结果按 material_id 去重合并(保留排序更优者),并记录命中 query,最后按策略排序。"""
  520. by_mid: dict[str, Material] = {}
  521. for query, materials in query_materials:
  522. hit = query.to_hit()
  523. for material in materials:
  524. material.recall_hit_queries = [hit]
  525. material.recall_strategy = query.strategy_name
  526. material.recall_query_text = query.query_text
  527. material.recall_config_code = query.config_code
  528. material.recall_element_dimension = query.element_dimension
  529. material.recall_point_type = query.point_type
  530. material.recall_standard_element = query.standard_element
  531. existing = by_mid.get(material.material_id)
  532. if existing is None:
  533. by_mid[material.material_id] = material
  534. continue
  535. merged_hits = existing.recall_hit_queries + [
  536. h for h in material.recall_hit_queries
  537. if h not in existing.recall_hit_queries
  538. ]
  539. if _material_rank(material) > _material_rank(existing):
  540. material.recall_hit_queries = merged_hits
  541. by_mid[material.material_id] = material
  542. else:
  543. existing.recall_hit_queries = merged_hits
  544. return _sort_materials_by_policy(list(by_mid.values()))
  545. def recall_materials_for_video(
  546. landing: LandingVideo,
  547. final_top_n: Optional[int] = DEFAULT_FINAL_TOP_N,
  548. source_labels: Optional[List[str]] = None,
  549. element_features: Optional[Iterable] = None,
  550. apply_cover_blacklist: bool = True,
  551. min_cost: Optional[float] = None,
  552. apply_cost_filter: bool = True,
  553. ) -> List[Material]:
  554. """素材召回:用 ODPS 多维特征并行召回并合并排序。
  555. 流程:
  556. 1. 从 ODPS features 生成 query:解构选题 + 实质三点。
  557. 2. 多 query 并行调用 batchByText。
  558. 3. 汇总、material_id 去重、score>=阈值、按 cost 倒序。
  559. 硬筛:相似度阈值 + 历史成本(cost>=min_cost,min_cost 默认取 config.RECALL_MIN_COST_YUAN)。
  560. 外部合作素材召回传 apply_cost_filter=False 关闭成本门槛,仅以访问 UV 门槛把关。
  561. 曝光/CTR 进入审批表但不拦截。
  562. """
  563. from config import (
  564. RECALL_ALPHA, RECALL_DAYS, RECALL_DECONSTRUCT_BOOST,
  565. RECALL_DISPLAY_K, RECALL_MIN_COST_YUAN, RECALL_PARALLEL_MAX_WORKERS,
  566. RECALL_QUERY_LIMIT_PER_VIDEO,
  567. RECALL_SIM_THRESHOLD, RECALL_SOURCE_LABELS,
  568. RECALL_W_CTR, RECALL_W_CVR, RECALL_W_FISSION_RATE,
  569. RECALL_W_OPEN_RATE, RECALL_W_ROI,
  570. )
  571. if min_cost is None:
  572. min_cost = RECALL_MIN_COST_YUAN
  573. cost_marker = "off" if not apply_cost_filter else f"{min_cost:.0f}"
  574. if element_features is None:
  575. element_features = landing.raw.get("element_features") or []
  576. queries = _build_recall_queries_from_features(
  577. element_features,
  578. query_limit=max(1, RECALL_QUERY_LIMIT_PER_VIDEO),
  579. )
  580. if not queries:
  581. logger.warning(
  582. "[material_recall] landing video_id=%d 无 ODPS 可用召回特征,返回空",
  583. landing.video_id,
  584. )
  585. return []
  586. logger.info(
  587. "[material_recall] landing video_id=%d 走 %d 个 ODPS 策略:%s",
  588. landing.video_id, len(queries),
  589. "; ".join(f"{q.strategy_name}:{q.query_text}->{q.config_code}" for q in queries),
  590. )
  591. query_materials: List[tuple[RecallQuery, List[Material]]] = []
  592. labels = source_labels or RECALL_SOURCE_LABELS
  593. max_workers = max(1, min(RECALL_PARALLEL_MAX_WORKERS, len(queries)))
  594. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  595. futures = [
  596. executor.submit(
  597. _call_batch_for_recall_query,
  598. query,
  599. display_k=RECALL_DISPLAY_K,
  600. days=RECALL_DAYS,
  601. sim_threshold=RECALL_SIM_THRESHOLD,
  602. alpha=RECALL_ALPHA,
  603. w_ctr=RECALL_W_CTR,
  604. w_cvr=RECALL_W_CVR,
  605. w_roi=RECALL_W_ROI,
  606. w_open_rate=RECALL_W_OPEN_RATE,
  607. w_fission_rate=RECALL_W_FISSION_RATE,
  608. deconstruct_boost=RECALL_DECONSTRUCT_BOOST,
  609. source_labels=labels,
  610. )
  611. for query in queries
  612. ]
  613. for future in as_completed(futures):
  614. try:
  615. query, items = future.result()
  616. except Exception as e:
  617. logger.error("[material_recall] 并行 batchByText 失败:%s", e)
  618. continue
  619. mats, stats = _items_to_materials(
  620. items,
  621. RECALL_SIM_THRESHOLD,
  622. apply_cover_blacklist=apply_cover_blacklist,
  623. min_cost=min_cost,
  624. apply_cost_filter=apply_cost_filter,
  625. )
  626. query_materials.append((query, mats))
  627. logger.info(
  628. "[material_recall] 策略=%s q=%r configCode=%s 返回 %d 条,⊘ 黑名单 %d,⊘ score<%.2f %d,⊘ cost<%s %d → 保留 %d",
  629. query.strategy_name, query.query_text[:30], query.config_code,
  630. len(items), stats["blacklist"], RECALL_SIM_THRESHOLD,
  631. stats["low_score"], cost_marker, stats["low_cost"],
  632. len(mats),
  633. )
  634. merged = _merge_materials_by_policy(query_materials)
  635. logger.info(
  636. "[material_recall] landing video_id=%d 多维召回合并后保留 %d 条(cost desc)",
  637. landing.video_id, len(merged),
  638. )
  639. if merged:
  640. return merged if final_top_n is None else merged[:final_top_n]
  641. for query in queries:
  642. # batchByText 当前可能对 MATERIAL 返回 0;降级到历史 matchByText 路径。
  643. for fallback_cc in _fallback_config_codes_for_strategy(query.config_code, landing):
  644. try:
  645. fallback_items = _call_match_by_text(
  646. query_text=query.query_text,
  647. config_code=fallback_cc,
  648. material_top_n=RECALL_DISPLAY_K,
  649. source_labels=labels,
  650. )
  651. except Exception as e:
  652. logger.error(
  653. "[material_recall] fallback matchByText %s 失败,试下一个:%s",
  654. fallback_cc, e,
  655. )
  656. continue
  657. fmats, fstats = _items_to_materials(
  658. fallback_items,
  659. RECALL_SIM_THRESHOLD,
  660. apply_cover_blacklist=apply_cover_blacklist,
  661. min_cost=min_cost,
  662. apply_cost_filter=apply_cost_filter,
  663. )
  664. fquery = RecallQuery(
  665. query_text=query.query_text,
  666. config_code=fallback_cc,
  667. element_dimension=query.element_dimension,
  668. point_type=query.point_type,
  669. standard_element=query.standard_element,
  670. contribution_score=query.contribution_score,
  671. dt=query.dt,
  672. )
  673. fmats = _merge_materials_by_policy([(fquery, fmats)])
  674. logger.info(
  675. "[material_recall] fallback=%s 返回 %d 条,⊘ 黑名单 %d,⊘ score<%.2f %d,⊘ cost<%s %d → 保留 %d(cost desc)",
  676. fallback_cc, len(fallback_items), fstats["blacklist"],
  677. RECALL_SIM_THRESHOLD, fstats["low_score"],
  678. cost_marker, fstats["low_cost"],
  679. len(fmats),
  680. )
  681. if fmats:
  682. return fmats if final_top_n is None else fmats[:final_top_n]
  683. logger.info(
  684. "[material_recall] landing video_id=%d 所有 %d ODPS 策略全失败,返回空(上层换 landing)",
  685. landing.video_id, len(queries),
  686. )
  687. return []