material_recall.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. """创意素材召回接口适配层(vector 服务)。
  2. 业务模型(用户 2026-06-08 确认):
  3. 对一个承接视频(LandingVideo)执行 3 种策略召回素材,合并去重,按 score 取 top N。
  4. 接口:POST https://api-internal.piaoquantv.com/videoVector/recallTest/matchByText
  5. 鉴权:不需要(内部 API)
  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 dataclasses import dataclass, field
  21. from typing import List, Optional
  22. import httpx
  23. from tools.video_recall import LandingVideo
  24. logger = logging.getLogger(__name__)
  25. VECTOR_BASE = os.getenv(
  26. "VECTOR_BASE_URL",
  27. "https://api-internal.piaoquantv.com/videoVector",
  28. )
  29. VECTOR_MATCH_BY_TEXT = f"{VECTOR_BASE}/recallTest/matchByText"
  30. VECTOR_BATCH_BY_TEXT = f"{VECTOR_BASE}/recallTest/batchByText"
  31. VECTOR_ALL_CONFIG_CODES = f"{VECTOR_BASE}/videoSearch/getAllConfigCodes"
  32. def fetch_all_config_codes() -> dict[str, str]:
  33. """动态获取 vector 服务支持的全部 configCode → 中文名。
  34. 返回 {configCode: 中文名}。
  35. 召回前先调一次,避免硬编码列表过时。
  36. """
  37. resp = httpx.get(VECTOR_ALL_CONFIG_CODES, timeout=15)
  38. resp.raise_for_status()
  39. data = resp.json()
  40. if data.get("code") not in (0, 200):
  41. raise RuntimeError(
  42. f"getAllConfigCodes 失败:code={data.get('code')} msg={data.get('msg')}"
  43. )
  44. return data.get("data") or {}
  45. # 字段维度策略(2026-06-08 用户最终确认)
  46. # - 维度 1 选题: queryText=demand_content_topic, configCode=VIDEO_TOPIC
  47. # - 维度 2 标准化元素: queryText=standard_element, configCode 按 point_type **唯一对应**:
  48. # - "灵感点" → INSPIRATION_SUBSTANCE
  49. # - "关键点" → KEYPOINT_SUBSTANCE
  50. # - "目的点" → PURPOSE_SUBSTANCE
  51. # - 其他/空 → 跳过(没办法确定走哪一路,no-guessing)
  52. # - 维度 3 标题: 不做(VIDEO_TITLE 对 MATERIAL 实测返回 0)
  53. POINT_TYPE_TO_SUBSTANCE = {
  54. "灵感点": "INSPIRATION_SUBSTANCE",
  55. "关键点": "KEYPOINT_SUBSTANCE",
  56. "目的点": "PURPOSE_SUBSTANCE",
  57. }
  58. POINT_TYPE_TO_VIDEO_CONFIG = {
  59. "灵感点": "VIDEO_INSPIRATION",
  60. "关键点": "VIDEO_KEYPOINT",
  61. "目的点": "VIDEO_PURPOSE",
  62. }
  63. # fallback(动态接口失败时用)
  64. MATERIAL_EFFECTIVE_CONFIG_CODES = ["VIDEO_TOPIC"] + list(POINT_TYPE_TO_SUBSTANCE.values())
  65. # 占位符值视为无效(piaoquantv 数据里 demandContentTopic 大量为 "-")
  66. PLACEHOLDER_VALUES = {"", "-", None}
  67. # 单 configCode 召回 top N(合并前)
  68. DEFAULT_PER_CC_TOP_N = 100
  69. # 最终合并去重后返回 top N
  70. DEFAULT_FINAL_TOP_N = 20
  71. # Cover URL 黑名单 — 已知尺寸/比例不符合腾讯创意要求的来源
  72. # (2026-06-09 用户决策:踩坑驱动渐进收紧,踩到新模式再加)
  73. # - "auto_reply_cards_cover" :自动回复卡片业务的 cover,实测 reject code 1801159
  74. EXCLUDED_COVER_URL_PATTERNS = (
  75. "auto_reply_cards_cover",
  76. )
  77. @dataclass
  78. class Material:
  79. """召回的创意素材。
  80. 2026-06-10 升级:从 batchByText 接口接收质量数据(cost/ctr/cvr/roi/impressions 等)。
  81. """
  82. material_id: str # 素材原始 ID
  83. score: float # 服务端综合分(wCtr=1 配置下 ≈ qualityScore,与 CTR 强相关)
  84. title: str = ""
  85. cover: str = ""
  86. video_url: str = ""
  87. # 投放质量数据(来自 materialDetail.quality)— 2026-06-10 升级新增
  88. cost: Optional[float] = None # 累计成本(元)
  89. ctr: Optional[float] = None # 点击率
  90. cvr: Optional[float] = None # 转化率
  91. roi: Optional[float] = None # ROI(收入/成本)
  92. impressions: Optional[int] = None # 累计曝光
  93. quality_score: Optional[float] = None # 服务端 qualityScore
  94. # 原始 item dict(以备后用)
  95. raw: dict = field(default_factory=dict, repr=False)
  96. def _call_match_by_text(
  97. query_text: str,
  98. config_code: str,
  99. material_top_n: int = DEFAULT_PER_CC_TOP_N,
  100. source_labels: Optional[List[str]] = None,
  101. timeout: int = 30,
  102. ) -> List[dict]:
  103. """单次调 matchByText,只要 MATERIAL 模态,返回原始 items 列表。"""
  104. body = {
  105. "queryText": query_text,
  106. "configCode": config_code,
  107. "modalities": ["MATERIAL"],
  108. "videoTopN": 0,
  109. "articleTopN": 0,
  110. "materialTopN": material_top_n,
  111. "topN": material_top_n,
  112. "displayK": material_top_n,
  113. }
  114. if source_labels:
  115. body["sourceLabels"] = source_labels
  116. logger.info(
  117. "[material_recall] matchByText q=%r configCode=%s topN=%d",
  118. query_text[:40], config_code, material_top_n,
  119. )
  120. resp = httpx.post(
  121. VECTOR_MATCH_BY_TEXT,
  122. json=body,
  123. headers={"content-type": "application/json", "accept": "application/json"},
  124. timeout=timeout,
  125. )
  126. resp.raise_for_status()
  127. data = resp.json()
  128. code = data.get("code")
  129. # CommonResponse 可能用 0 或 200 表示成功;以 data 字段为准
  130. if code not in (0, 200, "0", "200"):
  131. raise RuntimeError(
  132. f"vector matchByText 失败:code={code} msg={data.get('msg') or data.get('message')}"
  133. )
  134. payload = data.get("data") or {}
  135. items = payload.get("items") or []
  136. # 只留 MATERIAL 模态(防御性 — 万一返回混入)
  137. return [it for it in items if it.get("modality") == "MATERIAL"]
  138. def _pick_query_text(landing: LandingVideo) -> Optional[str]:
  139. """按优先级选 queryText:standard_element > demand_content_topic > title。
  140. 占位符 "-" / 空串视为无效。"""
  141. for cand in (landing.standard_element, landing.demand_content_topic, landing.title):
  142. if cand and cand not in PLACEHOLDER_VALUES:
  143. return cand
  144. return None
  145. def _pick_query_text_for_batch(landing: LandingVideo) -> Optional[str]:
  146. """选 queryText:standard_element > demand_content_topic > title。(保留兼容)"""
  147. for cand in (landing.standard_element, landing.demand_content_topic, landing.title):
  148. if cand and cand not in PLACEHOLDER_VALUES:
  149. return cand
  150. return None
  151. def _pick_query_strategies_for_batch(landing: LandingVideo) -> List[tuple]:
  152. """返回 (queryText, configCode, strategy_name) 策略候选(2026-06-10 用户最终确认)。
  153. 只用 2 个维度:
  154. 维度 1 - 标准化元素:queryText=standard_element, configCode 按 point_type 唯一对应
  155. - "灵感点" → INSPIRATION_SUBSTANCE
  156. - "关键点" → KEYPOINT_SUBSTANCE
  157. - "目的点" → PURPOSE_SUBSTANCE
  158. 维度 2 - 选题:queryText=demand_content_topic, configCode=VIDEO_TOPIC
  159. prepare 阶段:维度 1 失败 → 试维度 2 → 仍失败 → 换 landing。
  160. """
  161. strategies = []
  162. # 维度 1: 标准化元素(优先)
  163. if landing.standard_element and landing.standard_element not in PLACEHOLDER_VALUES:
  164. cc = POINT_TYPE_TO_SUBSTANCE.get(landing.point_type)
  165. if cc:
  166. strategies.append((landing.standard_element, cc, f"标准化元素-{landing.point_type}"))
  167. # 维度 2: 选题
  168. if landing.demand_content_topic and landing.demand_content_topic not in PLACEHOLDER_VALUES:
  169. strategies.append((landing.demand_content_topic, "VIDEO_TOPIC", "选题"))
  170. return strategies
  171. def _fallback_config_codes_for_strategy(config_code: str, landing: LandingVideo) -> List[str]:
  172. """batchByText 无结果时给 matchByText 的兼容 configCode。
  173. 2026-06-30 实测:batchByText 对 MATERIAL 返回 0 时,旧 matchByText 仍可在
  174. VIDEO_KEYPOINT/VIDEO_INSPIRATION 等历史向量字段命中素材。
  175. """
  176. out = [config_code]
  177. video_cc = POINT_TYPE_TO_VIDEO_CONFIG.get(landing.point_type)
  178. if video_cc and video_cc not in out:
  179. out.append(video_cc)
  180. if config_code != "VIDEO_TOPIC" and "VIDEO_TOPIC" not in out:
  181. out.append("VIDEO_TOPIC")
  182. return out
  183. def _call_batch_by_text(
  184. query_text: str,
  185. config_codes: List[str],
  186. display_k: int,
  187. days: int,
  188. sim_threshold: float,
  189. alpha: float,
  190. w_ctr: float, w_cvr: float, w_roi: float,
  191. w_open_rate: float, w_fission_rate: float,
  192. deconstruct_boost: float,
  193. source_labels: List[str],
  194. modalities: List[str] = None,
  195. timeout: int = 30,
  196. ) -> List[dict]:
  197. """调用 batchByText 接口(2026-06-10 升级).
  198. 服务端单次 embedding + 多 configCode 并行 ANN + 跨模态过滤 + ranking 加权 + 去重。
  199. """
  200. body = {
  201. "queryText": query_text,
  202. "configCodes": config_codes,
  203. "displayK": display_k,
  204. "modalities": modalities or ["MATERIAL"],
  205. "sourceLabels": source_labels,
  206. "days": days,
  207. "ranking": {
  208. "simThreshold": sim_threshold,
  209. "alpha": alpha,
  210. "wCtr": w_ctr, "wCvr": w_cvr, "wRoi": w_roi,
  211. "wOpenRate": w_open_rate, "wFissionRate": w_fission_rate,
  212. "deconstructBoost": deconstruct_boost,
  213. },
  214. }
  215. logger.info(
  216. "[material_recall] batchByText q=%r configCodes=%d displayK=%d simT=%.2f wCtr=%.2f",
  217. query_text[:40], len(config_codes), display_k, sim_threshold, w_ctr,
  218. )
  219. resp = httpx.post(
  220. VECTOR_BATCH_BY_TEXT,
  221. json=body,
  222. headers={"content-type": "application/json", "accept": "application/json"},
  223. timeout=timeout,
  224. )
  225. resp.raise_for_status()
  226. data = resp.json()
  227. if data.get("code") not in (0, 200, "0", "200"):
  228. raise RuntimeError(
  229. f"batchByText 失败:code={data.get('code')} msg={data.get('msg') or data.get('message')}"
  230. )
  231. payload = data.get("data") or {}
  232. return payload.get("items") or []
  233. def _items_to_materials(items: List[dict], min_impressions: int, min_ctr: float) -> tuple:
  234. """把 batchByText items 过滤 + 转 Material(2026-06-10 复用 helper)。
  235. 过滤条件(全部满足才保留):
  236. - modality=MATERIAL(防御性)
  237. - cover URL 不在黑名单
  238. - impressions > min_impressions
  239. - ctr >= min_ctr(2026-06-10 加,5% 太低)
  240. 返回 (materials, blacklist_n, low_imp_n, low_ctr_n)。
  241. """
  242. out: List[Material] = []
  243. excluded_blacklist = 0
  244. excluded_low_imp = 0
  245. excluded_low_ctr = 0
  246. for it in items:
  247. if it.get("modality") != "MATERIAL":
  248. continue
  249. mid = it.get("materialId") or (str(it["id"]) if it.get("id") is not None else None)
  250. if not mid:
  251. continue
  252. cover = it.get("cover") or ""
  253. if any(p in cover for p in EXCLUDED_COVER_URL_PATTERNS):
  254. excluded_blacklist += 1
  255. continue
  256. md = it.get("materialDetail") or {}
  257. q = md.get("quality") or {}
  258. imp = q.get("impressions") or 0
  259. if imp <= min_impressions:
  260. excluded_low_imp += 1
  261. continue
  262. ctr = q.get("ctr") or 0.0
  263. if ctr < min_ctr:
  264. excluded_low_ctr += 1
  265. continue
  266. out.append(Material(
  267. material_id=str(mid),
  268. score=float(it.get("score") or 0.0),
  269. title=it.get("title") or "",
  270. cover=cover,
  271. video_url=it.get("videoUrl") or "",
  272. cost=q.get("cost"),
  273. ctr=q.get("ctr"),
  274. cvr=q.get("cvr"),
  275. roi=q.get("roi"),
  276. impressions=imp,
  277. quality_score=q.get("qualityScore"),
  278. raw=it,
  279. ))
  280. return out, excluded_blacklist, excluded_low_imp, excluded_low_ctr
  281. def recall_materials_for_video(
  282. landing: LandingVideo,
  283. final_top_n: int = DEFAULT_FINAL_TOP_N,
  284. source_labels: Optional[List[str]] = None,
  285. ) -> List[Material]:
  286. """素材召回(2026-06-10 用户最终:2 维度 strategy + fallback,batchByText 单 configCode)。
  287. 流程:
  288. 1. _pick_query_strategies_for_batch(landing) 返回 1~2 个策略候选
  289. 维度 1: (standard_element, *_SUBSTANCE 按 point_type) — 优先
  290. 维度 2: (demand_content_topic, VIDEO_TOPIC)
  291. 2. 对每个策略调一次 batchByText(只传 1 个 configCode)
  292. 首次返回 ≥ 1 条 impressions>阈值 的 material → break,直接返回
  293. 3. 全部策略都失败 → 返回空 → 上层 prepare 换 landing
  294. 阈值不变保证质量(用户 2026-06-10 确认):依赖"多次尝试"提升命中率。
  295. """
  296. from config import (
  297. RECALL_ALPHA, RECALL_DAYS, RECALL_DECONSTRUCT_BOOST,
  298. RECALL_DISPLAY_K, RECALL_MIN_CTR, RECALL_MIN_IMPRESSIONS,
  299. RECALL_SIM_THRESHOLD, RECALL_SOURCE_LABELS,
  300. RECALL_W_CTR, RECALL_W_CVR, RECALL_W_FISSION_RATE,
  301. RECALL_W_OPEN_RATE, RECALL_W_ROI,
  302. )
  303. strategies = _pick_query_strategies_for_batch(landing)
  304. if not strategies:
  305. logger.warning(
  306. "[material_recall] landing video_id=%d 无可用策略(2 维度都缺),返回空",
  307. landing.video_id,
  308. )
  309. return []
  310. logger.info(
  311. "[material_recall] landing video_id=%d 走 %d 个策略",
  312. landing.video_id, len(strategies),
  313. )
  314. for query_text, config_code, name in strategies:
  315. logger.info(
  316. "[material_recall] 策略=%s q=%r configCode=%s",
  317. name, query_text[:30], config_code,
  318. )
  319. try:
  320. items = _call_batch_by_text(
  321. query_text=query_text,
  322. config_codes=[config_code], # ← 只传当前策略的 1 个
  323. display_k=RECALL_DISPLAY_K,
  324. days=RECALL_DAYS,
  325. sim_threshold=RECALL_SIM_THRESHOLD,
  326. alpha=RECALL_ALPHA,
  327. w_ctr=RECALL_W_CTR, w_cvr=RECALL_W_CVR, w_roi=RECALL_W_ROI,
  328. w_open_rate=RECALL_W_OPEN_RATE, w_fission_rate=RECALL_W_FISSION_RATE,
  329. deconstruct_boost=RECALL_DECONSTRUCT_BOOST,
  330. source_labels=source_labels or RECALL_SOURCE_LABELS,
  331. modalities=["MATERIAL"],
  332. )
  333. except Exception as e:
  334. logger.error(
  335. "[material_recall] 策略 %s 失败,试下一个:%s", name, e,
  336. )
  337. continue
  338. mats, n_bl, n_low_imp, n_low_ctr = _items_to_materials(
  339. items, RECALL_MIN_IMPRESSIONS, RECALL_MIN_CTR,
  340. )
  341. logger.info(
  342. "[material_recall] 服务端返回 %d 条,⊘ 黑名单 %d,⊘ imp<=%d %d,⊘ ctr<%.2f %d → 保留 %d",
  343. len(items), n_bl,
  344. RECALL_MIN_IMPRESSIONS, n_low_imp,
  345. RECALL_MIN_CTR, n_low_ctr,
  346. len(mats),
  347. )
  348. if mats:
  349. return mats[:final_top_n]
  350. # batchByText 当前可能对 MATERIAL 返回 0;降级到历史 matchByText 路径。
  351. for fallback_cc in _fallback_config_codes_for_strategy(config_code, landing):
  352. try:
  353. fallback_items = _call_match_by_text(
  354. query_text=query_text,
  355. config_code=fallback_cc,
  356. material_top_n=RECALL_DISPLAY_K,
  357. source_labels=source_labels or RECALL_SOURCE_LABELS,
  358. )
  359. except Exception as e:
  360. logger.error(
  361. "[material_recall] fallback matchByText %s 失败,试下一个:%s",
  362. fallback_cc, e,
  363. )
  364. continue
  365. fmats, fn_bl, fn_low_imp, fn_low_ctr = _items_to_materials(
  366. fallback_items, RECALL_MIN_IMPRESSIONS, RECALL_MIN_CTR,
  367. )
  368. logger.info(
  369. "[material_recall] fallback=%s 返回 %d 条,⊘ 黑名单 %d,⊘ imp<=%d %d,⊘ ctr<%.2f %d → 保留 %d",
  370. fallback_cc, len(fallback_items), fn_bl,
  371. RECALL_MIN_IMPRESSIONS, fn_low_imp,
  372. RECALL_MIN_CTR, fn_low_ctr,
  373. len(fmats),
  374. )
  375. if fmats:
  376. fmats.sort(
  377. key=lambda m: (
  378. m.ctr or 0,
  379. m.impressions or 0,
  380. m.quality_score or 0,
  381. m.score or 0,
  382. ),
  383. reverse=True,
  384. )
  385. return fmats[:final_top_n]
  386. # else 继续下一个策略
  387. logger.info(
  388. "[material_recall] landing video_id=%d 所有 %d 策略全失败,返回空(上层换 landing)",
  389. landing.video_id, len(strategies),
  390. )
  391. return []