progressive_screening.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810
  1. from __future__ import annotations
  2. import time
  3. import random
  4. from copy import deepcopy
  5. from dataclasses import dataclass
  6. from datetime import datetime, timezone
  7. from typing import Any, Callable
  8. from content_agent.business_modules import content_discovery, platform_access, rule_judgment
  9. from content_agent.business_modules.content_discovery import pattern_recall
  10. from content_agent.business_modules.content_discovery.fifty_plus import fifty_plus_score
  11. from content_agent.constants import RUNTIME_RECORD_SCHEMA_VERSION
  12. from content_agent.errors import ContentAgentError, ErrorCode
  13. from content_agent.interfaces import GeminiVideoClient, PlatformSearchClient, RuntimeFileStore
  14. from content_agent.record_payload import with_raw_payload
  15. INITIAL_BATCH_SIZE = 3
  16. MAX_PAGES = 3
  17. SEARCH_MIN_INTERVAL_SECONDS = 30.0
  18. DOUYIN_SEARCH_MIN_INTERVAL_SECONDS = 10.0
  19. DOUYIN_SEARCH_MAX_INTERVAL_SECONDS = 12.0
  20. SHORT_VIDEO_SEARCH_MIN_INTERVAL_SECONDS = 10.0
  21. SHORT_VIDEO_SEARCH_MAX_INTERVAL_SECONDS = 12.0
  22. PASS_ACTION = "ADD_TO_CONTENT_POOL"
  23. # 抖音拉作者画像(适老性)的门:只看 query 相关性,不看平台分,保证适老性始终进三项加权。
  24. # 值=抖音入池 query 下限(score_thresholds.douyin.pool_query=65);低于此靠 query 就被否,不拉省成本。
  25. PORTRAIT_QUERY_GATE = 65
  26. # 画像拉取重试次数(总尝试次数):救热点宝瞬时失败/限速。
  27. PORTRAIT_FETCH_ATTEMPTS = 2
  28. class SearchCallLimiter:
  29. def __init__(
  30. self,
  31. min_interval_seconds: float = SEARCH_MIN_INTERVAL_SECONDS,
  32. max_interval_seconds: float | None = None,
  33. *,
  34. now_fn: Callable[[], float] = time.monotonic,
  35. sleep_fn: Callable[[float], None] = time.sleep,
  36. random_fn: Callable[[float, float], float] = random.uniform,
  37. ) -> None:
  38. self.min_interval_seconds = min_interval_seconds
  39. self.max_interval_seconds = max_interval_seconds
  40. self.now_fn = now_fn
  41. self.sleep_fn = sleep_fn
  42. self.random_fn = random_fn
  43. self._last_call_at: float | None = None
  44. def wait(self) -> float:
  45. waited_seconds = 0.0
  46. if self._last_call_at is not None:
  47. remaining = self._next_interval_seconds() - (self.now_fn() - self._last_call_at)
  48. if remaining > 0:
  49. self.sleep_fn(remaining)
  50. waited_seconds = remaining
  51. self._last_call_at = self.now_fn()
  52. return waited_seconds
  53. def _next_interval_seconds(self) -> float:
  54. if self.max_interval_seconds is None or self.max_interval_seconds <= self.min_interval_seconds:
  55. return self.min_interval_seconds
  56. return self.random_fn(self.min_interval_seconds, self.max_interval_seconds)
  57. def make_search_limiter(platform: str = "") -> SearchCallLimiter:
  58. if platform == "douyin":
  59. return SearchCallLimiter(
  60. min_interval_seconds=DOUYIN_SEARCH_MIN_INTERVAL_SECONDS,
  61. max_interval_seconds=DOUYIN_SEARCH_MAX_INTERVAL_SECONDS,
  62. )
  63. if platform in {"kuaishou", "shipinhao"}:
  64. return SearchCallLimiter(
  65. min_interval_seconds=SHORT_VIDEO_SEARCH_MIN_INTERVAL_SECONDS,
  66. max_interval_seconds=SHORT_VIDEO_SEARCH_MAX_INTERVAL_SECONDS,
  67. )
  68. return SearchCallLimiter()
  69. @dataclass(frozen=True)
  70. class _FetchedPage:
  71. results: list[dict[str, Any]]
  72. search_duration_ms: int
  73. search_wait_ms: int
  74. search_request_duration_ms: int
  75. def run(
  76. *,
  77. run_id: str,
  78. policy_run_id: str,
  79. search_queries: list[dict[str, Any]],
  80. source_context: dict[str, Any],
  81. policy_bundle: dict[str, Any],
  82. platform_client: PlatformSearchClient,
  83. runtime: RuntimeFileStore,
  84. gemini_video_client: GeminiVideoClient,
  85. limiter: SearchCallLimiter | None = None,
  86. portrait_limiter: SearchCallLimiter | None = None,
  87. archive_dispatcher: Any | None = None,
  88. platform: str = "",
  89. ) -> dict[str, list[dict[str, Any]]]:
  90. if limiter is None and getattr(platform_client, "requires_progressive_search_rate_limit", False):
  91. limiter = make_search_limiter(platform)
  92. context = _ProgressiveContext(
  93. run_id=run_id,
  94. policy_run_id=policy_run_id,
  95. source_context=source_context,
  96. policy_bundle=policy_bundle,
  97. platform_client=platform_client,
  98. runtime=runtime,
  99. gemini_video_client=gemini_video_client,
  100. limiter=limiter,
  101. portrait_limiter=portrait_limiter,
  102. archive_dispatcher=archive_dispatcher,
  103. platform=platform,
  104. )
  105. try:
  106. for query in search_queries:
  107. context.process_query(query)
  108. if search_queries and context.query_failures and not context.platform_results:
  109. raise ContentAgentError(
  110. ErrorCode.PLATFORM_REQUEST_FAILED,
  111. "all platform queries failed",
  112. {"query_failures": context.query_failures},
  113. )
  114. context.write_runtime()
  115. return {
  116. "platform_results": context.platform_results,
  117. "query_failures": context.query_failures,
  118. "discovered_content_items": context.discovered_content_items,
  119. "content_media_records": context.content_media_records,
  120. "pattern_recall_evidence": context.pattern_recall_evidence,
  121. "evidence_bundles": context.evidence_bundles,
  122. "rule_decisions": context.rule_decisions,
  123. }
  124. finally:
  125. context.close_archive_dispatcher()
  126. class _ProgressiveContext:
  127. def __init__(
  128. self,
  129. *,
  130. run_id: str,
  131. policy_run_id: str,
  132. source_context: dict[str, Any],
  133. policy_bundle: dict[str, Any],
  134. platform_client: PlatformSearchClient,
  135. runtime: RuntimeFileStore,
  136. gemini_video_client: GeminiVideoClient,
  137. archive_dispatcher: Any | None,
  138. limiter: SearchCallLimiter | None,
  139. portrait_limiter: SearchCallLimiter | None = None,
  140. external_seen_content_ids: set[str] | None = None,
  141. recall_index_base: int = 0,
  142. decision_index_base: int = 0,
  143. platform: str = "",
  144. portrait_client: Any | None = None,
  145. ) -> None:
  146. self.run_id = run_id
  147. self.policy_run_id = policy_run_id
  148. self.source_context = source_context
  149. self.policy_bundle = policy_bundle
  150. self.platform_client = platform_client
  151. self.runtime = runtime
  152. self.gemini_video_client = gemini_video_client
  153. self.limiter = limiter
  154. self.portrait_limiter = portrait_limiter
  155. self.archive_dispatcher = archive_dispatcher
  156. self._archive_dispatcher_closed = False
  157. self.platform_results: list[dict[str, Any]] = []
  158. self.query_failures: list[dict[str, Any]] = []
  159. self.discovered_content_items: list[dict[str, Any]] = []
  160. self.content_media_records: list[dict[str, Any]] = []
  161. self.pattern_recall_evidence: list[dict[str, Any]] = []
  162. self.evidence_bundles: list[dict[str, Any]] = []
  163. self.rule_decisions: list[dict[str, Any]] = []
  164. self._platform_result_by_key: dict[tuple[str, str], dict[str, Any]] = {}
  165. self._record_indexes_by_key: dict[tuple[str, str], dict[str, int]] = {}
  166. self._queries_with_consumed_next_pages: set[str] = set()
  167. # M8C:游走复用统一搜索单元时注入的全 run 级状态。
  168. # external_seen_content_ids:与 _FrontierContext 共享的同一 set,承载首轮 + 跨层
  169. # 已发现 content_id;为 None 时(首轮)行为完全等价。
  170. # *_index_base:游走批次的 id 起算基线(=首轮计数),避免 recall/decision id 撞首轮。
  171. self._external_seen_content_ids = external_seen_content_ids
  172. self._recall_index_base = recall_index_base
  173. self._decision_index_base = decision_index_base
  174. # M9A:抖音 50+ 受众子分。platform!="douyin" 时不注入 content_audience_50plus 块,
  175. # evaluator 即走旧 50/50(非抖音零改动)。portrait_client 默认复用 platform_client。
  176. self.platform = platform
  177. self.portrait_client = portrait_client or platform_client
  178. # M9 修复:作者画像按作者缓存(画像是作者级,同作者结果一致)。避免对首轮大量视频
  179. # 逐条串行拉画像 → 同作者只拉一次,把调用量从"视频数"降到"作者数"。None=该作者拉取失败。
  180. self._portrait_by_author: dict[str, dict[str, Any] | None] = {}
  181. self._portrait_error_by_author: dict[str, str | None] = {}
  182. self._batch_event_index = 0
  183. def process_query(self, query: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
  184. """统一搜索单元(前3命中→剩余→翻页≤3页)。
  185. M8C:返回本次搜索新增的 discovered_content_items / rule_decisions 切片,
  186. 供游走 frontier 取回再判 allow_walk。首轮 run() 忽略返回值,行为等价。
  187. """
  188. start_discovered = len(self.discovered_content_items)
  189. start_decisions = len(self.rule_decisions)
  190. self._process_query_pages(query)
  191. return {
  192. "discovered_content_items": self.discovered_content_items[start_discovered:],
  193. "rule_decisions": self.rule_decisions[start_decisions:],
  194. }
  195. def process_prefetched_batch(
  196. self,
  197. query: dict[str, Any],
  198. results: list[dict[str, Any]],
  199. *,
  200. search_duration_ms: int = 0,
  201. search_wait_ms: int = 0,
  202. search_request_duration_ms: int = 0,
  203. search_timing_source: str = "prefetched_author_works",
  204. ) -> dict[str, list[dict[str, Any]]]:
  205. """处理已抓取的一批结果(作者作品:列表抓取+截断,不分页、不限速)。
  206. 复用 _process_batch 的 discovery→recall→decision+去重+index 路径,
  207. 返回本批新增切片。去重/index 与统一搜索单元共享同一 context 状态。
  208. """
  209. start_discovered = len(self.discovered_content_items)
  210. start_decisions = len(self.rule_decisions)
  211. if results:
  212. self._process_batch(
  213. query,
  214. results,
  215. page_number=1,
  216. page_item_count=len(results),
  217. batch_kind="author_works",
  218. rank_offset=0,
  219. search_duration_ms=search_duration_ms,
  220. search_wait_ms=search_wait_ms,
  221. search_request_duration_ms=search_request_duration_ms,
  222. search_timing_source=search_timing_source,
  223. )
  224. return {
  225. "discovered_content_items": self.discovered_content_items[start_discovered:],
  226. "rule_decisions": self.rule_decisions[start_decisions:],
  227. }
  228. def _process_query_pages(self, query: dict[str, Any]) -> None:
  229. first_fetch = self._fetch_page(query, page_number=1, cursor=query.get("page_cursor"))
  230. if first_fetch is None:
  231. return
  232. first_page = first_fetch.results
  233. page_count = len(first_page)
  234. if page_count == 0:
  235. return
  236. first_batch = first_page[:INITIAL_BATCH_SIZE]
  237. remainder_batch = first_page[INITIAL_BATCH_SIZE:]
  238. passed_first = self._process_batch(
  239. query,
  240. first_batch,
  241. page_number=1,
  242. page_item_count=page_count,
  243. batch_kind="initial_top3",
  244. rank_offset=0,
  245. search_duration_ms=first_fetch.search_duration_ms,
  246. search_wait_ms=first_fetch.search_wait_ms,
  247. search_request_duration_ms=first_fetch.search_request_duration_ms,
  248. search_timing_source="page_fetch",
  249. )
  250. if not passed_first or not remainder_batch:
  251. return
  252. passed_remainder = self._process_batch(
  253. query,
  254. remainder_batch,
  255. page_number=1,
  256. page_item_count=page_count,
  257. batch_kind="page_remainder",
  258. rank_offset=INITIAL_BATCH_SIZE,
  259. search_duration_ms=0,
  260. search_wait_ms=0,
  261. search_request_duration_ms=0,
  262. search_timing_source="same_page_remainder",
  263. )
  264. if not passed_remainder:
  265. return
  266. cursor = _page_next_cursor(first_page)
  267. has_more = _page_has_more(first_page)
  268. page_number = 2
  269. while page_number <= MAX_PAGES and has_more and cursor:
  270. fetched_page = self._fetch_page(query, page_number=page_number, cursor=cursor)
  271. if fetched_page is None or not fetched_page.results:
  272. return
  273. page = fetched_page.results
  274. self._queries_with_consumed_next_pages.add(str(query["search_query_id"]))
  275. page_count = len(page)
  276. passed_page = self._process_batch(
  277. query,
  278. page,
  279. page_number=page_number,
  280. page_item_count=page_count,
  281. batch_kind=f"page_{page_number:02d}",
  282. rank_offset=0,
  283. search_duration_ms=fetched_page.search_duration_ms,
  284. search_wait_ms=fetched_page.search_wait_ms,
  285. search_request_duration_ms=fetched_page.search_request_duration_ms,
  286. search_timing_source="page_fetch",
  287. )
  288. if page_number >= MAX_PAGES or not passed_page:
  289. return
  290. cursor = _page_next_cursor(page)
  291. has_more = _page_has_more(page)
  292. page_number += 1
  293. def _fetch_page(
  294. self,
  295. query: dict[str, Any],
  296. *,
  297. page_number: int,
  298. cursor: Any,
  299. ) -> _FetchedPage | None:
  300. page_query = dict(query)
  301. if page_number > 1:
  302. page_query["page_cursor"] = str(cursor or "")
  303. try:
  304. search_started_at = time.monotonic()
  305. waited_seconds = self.limiter.wait() if self.limiter is not None else 0.0
  306. request_started_at = time.monotonic()
  307. search = getattr(self.platform_client, "search_full_page_metadata", None)
  308. if not callable(search):
  309. search = getattr(self.platform_client, "search_full_page", None)
  310. if not callable(search):
  311. search = self.platform_client.search
  312. results = list(search(page_query))
  313. request_ended_at = time.monotonic()
  314. return _FetchedPage(
  315. results=results,
  316. search_duration_ms=_elapsed_ms(search_started_at, request_ended_at),
  317. search_wait_ms=int(waited_seconds * 1000),
  318. search_request_duration_ms=_elapsed_ms(request_started_at, request_ended_at),
  319. )
  320. except Exception as exc:
  321. failure_query = page_query if page_number == 1 else {
  322. **page_query,
  323. "search_query_id": f"{query['search_query_id']}_progressive_page_{page_number:03d}",
  324. }
  325. failure = platform_access._query_failure(failure_query, exc) # noqa: SLF001
  326. failure["progressive_page_number"] = page_number
  327. self.query_failures.append(failure)
  328. return None
  329. def _process_batch(
  330. self,
  331. query: dict[str, Any],
  332. page_results: list[dict[str, Any]],
  333. *,
  334. page_number: int,
  335. page_item_count: int,
  336. batch_kind: str,
  337. rank_offset: int,
  338. search_duration_ms: int = 0,
  339. search_wait_ms: int = 0,
  340. search_request_duration_ms: int = 0,
  341. search_timing_source: str = "unknown",
  342. ) -> bool:
  343. batch_started_at = time.monotonic()
  344. batch = []
  345. for rank, result in enumerate(page_results, start=1):
  346. key = _content_key(result)
  347. if key and key in self._platform_result_by_key:
  348. existing = self._platform_result_by_key[key]
  349. platform_access._append_query_source(existing, query) # noqa: SLF001
  350. self._propagate_query_source_merge(key, existing)
  351. continue
  352. # M8C:游走跨层 / 首轮去重——已发现的 content_id 静默跳过(不二次判定、不撞 DB 唯一索引)。
  353. if (
  354. key
  355. and self._external_seen_content_ids is not None
  356. and key[1] in self._external_seen_content_ids
  357. ):
  358. continue
  359. result = self._hydrate_result_media(result, query)
  360. prepared = self._prepare_result(
  361. query,
  362. result,
  363. page_number=page_number,
  364. page_item_count=page_item_count,
  365. batch_kind=batch_kind,
  366. rank=rank_offset + rank,
  367. )
  368. key = _content_key(prepared)
  369. if not key:
  370. batch.append(prepared)
  371. continue
  372. self._platform_result_by_key[key] = prepared
  373. if self._external_seen_content_ids is not None:
  374. self._external_seen_content_ids.add(key[1])
  375. batch.append(prepared)
  376. if not batch:
  377. return False
  378. discovery_started_at = time.monotonic()
  379. discovered = content_discovery.run(
  380. self.run_id,
  381. self.policy_run_id,
  382. batch,
  383. self.source_context,
  384. self.runtime,
  385. write_runtime=False,
  386. )
  387. discovery_duration_ms = _elapsed_ms(discovery_started_at, time.monotonic())
  388. recalled = pattern_recall.run(
  389. self.run_id,
  390. self.policy_run_id,
  391. discovered["discovered_content_items"],
  392. discovered["content_media_records"],
  393. discovered["evidence_bundles"],
  394. self.source_context,
  395. self.runtime,
  396. self.gemini_video_client,
  397. start_index=self._recall_index_base + len(self.discovered_content_items) + 1,
  398. write_runtime=False,
  399. archive_dispatcher=self.archive_dispatcher,
  400. )
  401. recall_timing = recalled.get("batch_timing") or {}
  402. portrait_started_at = time.monotonic()
  403. self._inject_fifty_plus(recalled["evidence_bundles"])
  404. portrait_duration_ms = _elapsed_ms(portrait_started_at, time.monotonic())
  405. rule_started_at = time.monotonic()
  406. decisions = rule_judgment.run(
  407. self.run_id,
  408. self.policy_run_id,
  409. recalled["evidence_bundles"],
  410. self.policy_bundle,
  411. self.runtime,
  412. start_index=self._decision_index_base + len(self.rule_decisions) + 1,
  413. write_runtime=False,
  414. )
  415. rule_duration_ms = _elapsed_ms(rule_started_at, time.monotonic())
  416. self._accumulate(batch, recalled, decisions)
  417. has_pass = any(decision.get("decision_action") == PASS_ACTION for decision in decisions)
  418. self._record_batch_timing_event(
  419. query,
  420. batch,
  421. decisions,
  422. page_number=page_number,
  423. page_item_count=page_item_count,
  424. batch_kind=batch_kind,
  425. search_duration_ms=search_duration_ms,
  426. search_wait_ms=search_wait_ms,
  427. search_request_duration_ms=search_request_duration_ms,
  428. search_timing_source=search_timing_source,
  429. oss_batch_duration_ms=int(recall_timing.get("oss_batch_duration_ms") or 0),
  430. qwen_batch_duration_ms=int(recall_timing.get("qwen_batch_duration_ms") or 0),
  431. portrait_duration_ms=portrait_duration_ms,
  432. rule_duration_ms=rule_duration_ms,
  433. discovery_duration_ms=discovery_duration_ms,
  434. batch_duration_ms=_elapsed_ms(batch_started_at, time.monotonic()),
  435. has_pass=has_pass,
  436. )
  437. return has_pass
  438. def _inject_fifty_plus(self, bundles: list[dict[str, Any]]) -> None:
  439. """M9A:仅抖音,对相关性达标(query≥PORTRAIT_QUERY_GATE)的视频拉作者画像补 50+。
  440. 门只看 query 相关性、不看平台分:适老性要始终进三项加权(35/35/30),
  441. 否则"平台互动一般但极度适老"的视频会被错杀(拍板 2026-06-22)。阈值=抖音入池 query 下限 65,
  442. 低于此的视频靠 query 这关就被否、适老性也救不回来,不拉(省成本)。
  443. 单点覆盖首轮 + 游走。注入 bundle["content_audience_50plus"]:
  444. not_attempted(相关性不够,没拉)/ ok(画像算出)/ unavailable(拉取失败)/ incomplete(字段缺)。
  445. 非抖音不注入该块 → evaluator 走旧 50/50。
  446. """
  447. if self.platform != "douyin":
  448. return
  449. fetch_portrait = getattr(self.portrait_client, "fetch_account_fans_portrait", None)
  450. if not callable(fetch_portrait):
  451. # 平台 client 不提供画像(mock/replay)→ 不注入,evaluator 走旧 50/50。
  452. return
  453. for bundle in bundles:
  454. query = _get_path(bundle, "pattern_match_result.query_relevance_score")
  455. if not (_is_number(query) and query >= PORTRAIT_QUERY_GATE):
  456. bundle["content_audience_50plus"] = {"status": "not_attempted"}
  457. continue
  458. author_id = _get_path(bundle, "content.author.platform_author_id")
  459. if not author_id:
  460. bundle["content_audience_50plus"] = {"status": "incomplete"}
  461. continue
  462. author_id = str(author_id)
  463. if author_id in self._portrait_by_author:
  464. portrait = self._portrait_by_author[author_id] # 命中缓存,不再 30s 限速
  465. fetch_error = self._portrait_error_by_author.get(author_id)
  466. else:
  467. portrait, fetch_error = None, None
  468. for attempt in range(PORTRAIT_FETCH_ATTEMPTS): # 重试,救瞬时失败/限速
  469. try:
  470. portrait = fetch_portrait(author_id)
  471. fetch_error = None
  472. break
  473. except Exception as exc: # 不再吞掉原因:记下来供技术详情展示
  474. fetch_error = f"{type(exc).__name__}: {exc}".strip()[:200]
  475. portrait = None
  476. self._portrait_by_author[author_id] = portrait
  477. self._portrait_error_by_author[author_id] = fetch_error
  478. if portrait is None:
  479. bundle["content_audience_50plus"] = {
  480. "status": "unavailable",
  481. "failure_reason": fetch_error or "热点宝画像接口未返回数据",
  482. }
  483. continue
  484. fans_age = _get_path(portrait, "fans.age.data")
  485. result = fifty_plus_score(fans_age)
  486. block: dict[str, Any] = {"status": result["status"]}
  487. if result["status"] == "ok":
  488. block["score"] = result["score"]
  489. block["components"] = {
  490. key: result[key]
  491. for key in ("band", "band_tgi", "band_percent", "tgi_score", "percent_score")
  492. }
  493. bundle["content_audience_50plus"] = block
  494. def _record_batch_timing_event(
  495. self,
  496. query: dict[str, Any],
  497. batch: list[dict[str, Any]],
  498. decisions: list[dict[str, Any]],
  499. *,
  500. page_number: int,
  501. page_item_count: int,
  502. batch_kind: str,
  503. search_duration_ms: int,
  504. search_wait_ms: int,
  505. search_request_duration_ms: int,
  506. search_timing_source: str,
  507. oss_batch_duration_ms: int,
  508. qwen_batch_duration_ms: int,
  509. portrait_duration_ms: int,
  510. rule_duration_ms: int,
  511. discovery_duration_ms: int,
  512. batch_duration_ms: int,
  513. has_pass: bool,
  514. ) -> None:
  515. self._batch_event_index += 1
  516. action_counts: dict[str, int] = {}
  517. for decision in decisions:
  518. action = str(decision.get("decision_action") or "unknown")
  519. action_counts[action] = action_counts.get(action, 0) + 1
  520. raw_payload = {
  521. "batch_timing_schema_version": "progressive_batch_timing.v1",
  522. "progressive_batch_id": (
  523. f"{query['search_query_id']}_p{page_number:02d}_{batch_kind}"
  524. ),
  525. "search_query_id": query.get("search_query_id"),
  526. "search_query": query.get("search_query"),
  527. "search_query_generation_method": query.get("search_query_generation_method"),
  528. "page_number": page_number,
  529. "page_item_count": page_item_count,
  530. "batch_kind": batch_kind,
  531. "batch_video_count": len(batch),
  532. "decision_action_counts": action_counts,
  533. "has_add_to_content_pool": has_pass,
  534. "search_duration_ms": search_duration_ms,
  535. "search_wait_ms": search_wait_ms,
  536. "search_request_duration_ms": search_request_duration_ms,
  537. "search_timing_source": search_timing_source,
  538. "oss_batch_duration_ms": oss_batch_duration_ms,
  539. "qwen_batch_duration_ms": qwen_batch_duration_ms,
  540. "portrait_duration_ms": portrait_duration_ms,
  541. "rule_duration_ms": rule_duration_ms,
  542. "discovery_duration_ms": discovery_duration_ms,
  543. "batch_duration_ms": batch_duration_ms,
  544. "content_ids": [str(item.get("platform_content_id") or "") for item in batch],
  545. }
  546. row = with_raw_payload(
  547. {
  548. "record_schema_version": RUNTIME_RECORD_SCHEMA_VERSION,
  549. "run_id": self.run_id,
  550. "policy_run_id": self.policy_run_id,
  551. "event_id": (
  552. f"evt_progressive_batch_{self._batch_event_index:04d}_"
  553. f"{_safe_token(query.get('search_query_id'))}_p{page_number:02d}_{batch_kind}"
  554. ),
  555. "event_type": "progressive_batch_timing",
  556. "status": "success",
  557. "input_ref": "platform_results",
  558. "output_ref": "rule_decisions",
  559. "error_code": None,
  560. "message": (
  561. f"{batch_kind} videos={len(batch)} add={action_counts.get(PASS_ACTION, 0)}"
  562. ),
  563. "created_at": datetime.now(timezone.utc).isoformat(),
  564. }
  565. )
  566. row["raw_payload"].update(raw_payload)
  567. self.runtime.append_jsonl(self.run_id, "run_events.jsonl", [row])
  568. def _prepare_result(
  569. self,
  570. query: dict[str, Any],
  571. result: dict[str, Any],
  572. *,
  573. page_number: int,
  574. page_item_count: int,
  575. batch_kind: str,
  576. rank: int,
  577. ) -> dict[str, Any]:
  578. runtime_result = _strip_transient_fields(result)
  579. prepared = platform_access._with_query_source(runtime_result, query) # noqa: SLF001
  580. original_has_more = bool(prepared.get("has_more"))
  581. original_next_cursor = str(prepared.get("next_cursor") or "")
  582. prepared.update(
  583. {
  584. "search_query_id": query["search_query_id"],
  585. "content_discovery_id": _content_discovery_id(query["search_query_id"], page_number, rank),
  586. "progressive_batch_id": (
  587. f"{query['search_query_id']}_p{page_number:02d}_{batch_kind}"
  588. ),
  589. "progressive_page_number": page_number,
  590. "progressive_batch_kind": batch_kind,
  591. "progressive_page_item_count": page_item_count,
  592. "progressive_item_rank_in_page": rank,
  593. "progressive_original_has_more": original_has_more,
  594. "progressive_original_next_cursor": original_next_cursor,
  595. }
  596. )
  597. return prepared
  598. def _hydrate_result_media(
  599. self,
  600. result: dict[str, Any],
  601. query: dict[str, Any],
  602. ) -> dict[str, Any]:
  603. hydrate = getattr(self.platform_client, "hydrate_search_result_media", None)
  604. if not callable(hydrate):
  605. return result
  606. return dict(hydrate(result, query))
  607. def _accumulate(
  608. self,
  609. batch: list[dict[str, Any]],
  610. recalled: dict[str, Any],
  611. decisions: list[dict[str, Any]],
  612. ) -> None:
  613. for offset, result in enumerate(batch):
  614. key = _content_key(result)
  615. if key:
  616. self._record_indexes_by_key[key] = {
  617. "platform_result": len(self.platform_results) + offset,
  618. "discovered": len(self.discovered_content_items) + offset,
  619. "evidence_bundle": len(self.evidence_bundles) + offset,
  620. "decision": len(self.rule_decisions) + offset,
  621. }
  622. self.platform_results.extend(batch)
  623. self.discovered_content_items.extend(recalled["discovered_content_items"])
  624. self.content_media_records.extend(recalled["content_media_records"])
  625. self.pattern_recall_evidence.extend(recalled["pattern_recall_evidence"])
  626. self.evidence_bundles.extend(recalled["evidence_bundles"])
  627. self.rule_decisions.extend(decisions)
  628. def _propagate_query_source_merge(
  629. self,
  630. key: tuple[str, str],
  631. merged_result: dict[str, Any],
  632. ) -> None:
  633. indexes = self._record_indexes_by_key.get(key)
  634. if not indexes:
  635. return
  636. fields = [
  637. "query_sources",
  638. "matched_search_query_ids",
  639. "matched_search_queries",
  640. "matched_search_query_generation_methods",
  641. ]
  642. for collection_name in ["discovered_content_items"]:
  643. row = getattr(self, collection_name)[indexes["discovered"]]
  644. _copy_fields(row, merged_result, fields)
  645. bundle = self.evidence_bundles[indexes["evidence_bundle"]]
  646. _copy_fields(bundle.setdefault("source_evidence", {}), merged_result, fields)
  647. decision = self.rule_decisions[indexes["decision"]]
  648. _copy_fields(decision.setdefault("source_evidence", {}), merged_result, fields)
  649. raw_payload = decision.setdefault("raw_payload", {})
  650. if isinstance(raw_payload.get("source_evidence"), dict):
  651. _copy_fields(raw_payload["source_evidence"], merged_result, fields)
  652. def write_runtime(self) -> None:
  653. if self.archive_dispatcher is not None:
  654. self._merge_archive_updates(self.archive_dispatcher.drain_completed())
  655. self.runtime.append_jsonl(self.run_id, "content_media_records.jsonl", self.content_media_records)
  656. self.runtime.append_jsonl(self.run_id, "pattern_recall_evidence.jsonl", self.pattern_recall_evidence)
  657. self._hide_consumed_query_cursors()
  658. self.runtime.append_jsonl(self.run_id, "discovered_content_items.jsonl", self.discovered_content_items)
  659. self.runtime.append_jsonl(self.run_id, "rule_decisions.jsonl", self.rule_decisions)
  660. if self.archive_dispatcher is not None:
  661. self.archive_dispatcher.enable_runtime_writes()
  662. def close_archive_dispatcher(self) -> None:
  663. if self.archive_dispatcher is None or self._archive_dispatcher_closed:
  664. return
  665. self._archive_dispatcher_closed = True
  666. self.archive_dispatcher.shutdown(wait=False)
  667. def _merge_archive_updates(self, updates: list[dict[str, Any]]) -> None:
  668. if not updates:
  669. return
  670. by_key = {
  671. (row.get("platform"), row.get("platform_content_id")): row
  672. for row in updates
  673. if row.get("platform_content_id")
  674. }
  675. if not by_key:
  676. return
  677. self.content_media_records = [
  678. by_key.get((row.get("platform"), row.get("platform_content_id")), row)
  679. for row in self.content_media_records
  680. ]
  681. def _hide_consumed_query_cursors(self) -> None:
  682. if not self._queries_with_consumed_next_pages:
  683. return
  684. for row in self.platform_results:
  685. self._hide_cursor_if_consumed(row)
  686. for row in self.discovered_content_items:
  687. self._hide_cursor_if_consumed(row)
  688. for bundle in self.evidence_bundles:
  689. source_evidence = bundle.get("source_evidence")
  690. if isinstance(source_evidence, dict):
  691. self._hide_cursor_if_consumed(source_evidence)
  692. for decision in self.rule_decisions:
  693. source_evidence = decision.get("source_evidence")
  694. if isinstance(source_evidence, dict):
  695. self._hide_cursor_if_consumed(source_evidence)
  696. raw_payload = decision.get("raw_payload")
  697. if isinstance(raw_payload, dict) and isinstance(raw_payload.get("source_evidence"), dict):
  698. self._hide_cursor_if_consumed(raw_payload["source_evidence"])
  699. def _hide_cursor_if_consumed(self, row: dict[str, Any]) -> None:
  700. if str(row.get("search_query_id") or "") not in self._queries_with_consumed_next_pages:
  701. return
  702. row["has_more"] = False
  703. row["next_cursor"] = ""
  704. raw_payload = row.get("raw_payload")
  705. if isinstance(raw_payload, dict):
  706. raw_payload["has_more"] = False
  707. raw_payload["next_cursor"] = ""
  708. def _get_path(data: Any, path: str) -> Any:
  709. cur = data
  710. for part in path.split("."):
  711. if not isinstance(cur, dict):
  712. return None
  713. cur = cur.get(part)
  714. return cur
  715. def _elapsed_ms(start: float, end: float) -> int:
  716. return max(0, int((end - start) * 1000))
  717. def _safe_token(value: Any) -> str:
  718. token = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in str(value or "query"))
  719. return token[:80] or "query"
  720. def _is_number(value: Any) -> bool:
  721. return isinstance(value, (int, float)) and not isinstance(value, bool)
  722. def _content_key(result: dict[str, Any]) -> tuple[str, str] | None:
  723. content_id = str(result.get("platform_content_id") or "")
  724. if not content_id:
  725. return None
  726. return str(result.get("platform") or ""), content_id
  727. def _strip_transient_fields(result: dict[str, Any]) -> dict[str, Any]:
  728. return {key: value for key, value in result.items() if not str(key).startswith("_platform_")}
  729. def _content_discovery_id(search_query_id: str, page_number: int, rank: int) -> str:
  730. if page_number == 1:
  731. return f"{search_query_id}_content_{rank:03d}"
  732. return f"{search_query_id}_page_{page_number:03d}_content_{rank:03d}"
  733. def _page_has_more(results: list[dict[str, Any]]) -> bool:
  734. return any(bool(result.get("has_more")) for result in results)
  735. def _page_next_cursor(results: list[dict[str, Any]]) -> str:
  736. for result in results:
  737. cursor = str(result.get("next_cursor") or "")
  738. if cursor:
  739. return cursor
  740. return ""
  741. def _copy_fields(target: dict[str, Any], source: dict[str, Any], fields: list[str]) -> None:
  742. for field in fields:
  743. if field in source:
  744. target[field] = deepcopy(source[field])
  745. raw_payload = target.get("raw_payload")
  746. if isinstance(raw_payload, dict):
  747. for field in fields:
  748. if field in source:
  749. raw_payload[field] = deepcopy(source[field])