|
|
@@ -2,13 +2,17 @@ from __future__ import annotations
|
|
|
|
|
|
import time
|
|
|
from copy import deepcopy
|
|
|
+from dataclasses import dataclass
|
|
|
+from datetime import datetime, timezone
|
|
|
from typing import Any, Callable
|
|
|
|
|
|
from content_agent.business_modules import content_discovery, platform_access, rule_judgment
|
|
|
from content_agent.business_modules.content_discovery import pattern_recall
|
|
|
from content_agent.business_modules.content_discovery.fifty_plus import fifty_plus_score
|
|
|
+from content_agent.constants import RUNTIME_RECORD_SCHEMA_VERSION
|
|
|
from content_agent.errors import ContentAgentError, ErrorCode
|
|
|
from content_agent.interfaces import GeminiVideoClient, PlatformSearchClient, RuntimeFileStore
|
|
|
+from content_agent.record_payload import with_raw_payload
|
|
|
|
|
|
|
|
|
INITIAL_BATCH_SIZE = 3
|
|
|
@@ -35,12 +39,23 @@ class SearchCallLimiter:
|
|
|
self.sleep_fn = sleep_fn
|
|
|
self._last_call_at: float | None = None
|
|
|
|
|
|
- def wait(self) -> None:
|
|
|
+ def wait(self) -> float:
|
|
|
+ waited_seconds = 0.0
|
|
|
if self._last_call_at is not None:
|
|
|
remaining = self.min_interval_seconds - (self.now_fn() - self._last_call_at)
|
|
|
if remaining > 0:
|
|
|
self.sleep_fn(remaining)
|
|
|
+ waited_seconds = remaining
|
|
|
self._last_call_at = self.now_fn()
|
|
|
+ return waited_seconds
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class _FetchedPage:
|
|
|
+ results: list[dict[str, Any]]
|
|
|
+ search_duration_ms: int
|
|
|
+ search_wait_ms: int
|
|
|
+ search_request_duration_ms: int
|
|
|
|
|
|
|
|
|
def run(
|
|
|
@@ -54,11 +69,14 @@ def run(
|
|
|
runtime: RuntimeFileStore,
|
|
|
gemini_video_client: GeminiVideoClient,
|
|
|
limiter: SearchCallLimiter | None = None,
|
|
|
+ portrait_limiter: SearchCallLimiter | None = None,
|
|
|
archive_dispatcher: Any | None = None,
|
|
|
platform: str = "",
|
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
|
if limiter is None and getattr(platform_client, "requires_progressive_search_rate_limit", False):
|
|
|
limiter = SearchCallLimiter()
|
|
|
+ if portrait_limiter is None and getattr(platform_client, "requires_progressive_search_rate_limit", False):
|
|
|
+ portrait_limiter = SearchCallLimiter()
|
|
|
context = _ProgressiveContext(
|
|
|
run_id=run_id,
|
|
|
policy_run_id=policy_run_id,
|
|
|
@@ -68,6 +86,7 @@ def run(
|
|
|
runtime=runtime,
|
|
|
gemini_video_client=gemini_video_client,
|
|
|
limiter=limiter,
|
|
|
+ portrait_limiter=portrait_limiter,
|
|
|
archive_dispatcher=archive_dispatcher,
|
|
|
platform=platform,
|
|
|
)
|
|
|
@@ -107,8 +126,9 @@ class _ProgressiveContext:
|
|
|
platform_client: PlatformSearchClient,
|
|
|
runtime: RuntimeFileStore,
|
|
|
gemini_video_client: GeminiVideoClient,
|
|
|
- limiter: SearchCallLimiter | None,
|
|
|
archive_dispatcher: Any | None,
|
|
|
+ limiter: SearchCallLimiter | None,
|
|
|
+ portrait_limiter: SearchCallLimiter | None = None,
|
|
|
external_seen_content_ids: set[str] | None = None,
|
|
|
recall_index_base: int = 0,
|
|
|
decision_index_base: int = 0,
|
|
|
@@ -123,6 +143,7 @@ class _ProgressiveContext:
|
|
|
self.runtime = runtime
|
|
|
self.gemini_video_client = gemini_video_client
|
|
|
self.limiter = limiter
|
|
|
+ self.portrait_limiter = portrait_limiter
|
|
|
self.archive_dispatcher = archive_dispatcher
|
|
|
self._archive_dispatcher_closed = False
|
|
|
self.platform_results: list[dict[str, Any]] = []
|
|
|
@@ -150,6 +171,7 @@ class _ProgressiveContext:
|
|
|
# 逐条 30s 限速拉画像 → 同作者只拉一次,把调用量从"视频数"降到"作者数"。None=该作者拉取失败。
|
|
|
self._portrait_by_author: dict[str, dict[str, Any] | None] = {}
|
|
|
self._portrait_error_by_author: dict[str, str | None] = {}
|
|
|
+ self._batch_event_index = 0
|
|
|
|
|
|
def process_query(self, query: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
|
|
|
"""统一搜索单元(前3命中→剩余→翻页≤3页)。
|
|
|
@@ -166,7 +188,14 @@ class _ProgressiveContext:
|
|
|
}
|
|
|
|
|
|
def process_prefetched_batch(
|
|
|
- self, query: dict[str, Any], results: list[dict[str, Any]]
|
|
|
+ self,
|
|
|
+ query: dict[str, Any],
|
|
|
+ results: list[dict[str, Any]],
|
|
|
+ *,
|
|
|
+ search_duration_ms: int = 0,
|
|
|
+ search_wait_ms: int = 0,
|
|
|
+ search_request_duration_ms: int = 0,
|
|
|
+ search_timing_source: str = "prefetched_author_works",
|
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
|
"""处理已抓取的一批结果(作者作品:列表抓取+截断,不分页、不限速)。
|
|
|
|
|
|
@@ -183,6 +212,10 @@ class _ProgressiveContext:
|
|
|
page_item_count=len(results),
|
|
|
batch_kind="author_works",
|
|
|
rank_offset=0,
|
|
|
+ search_duration_ms=search_duration_ms,
|
|
|
+ search_wait_ms=search_wait_ms,
|
|
|
+ search_request_duration_ms=search_request_duration_ms,
|
|
|
+ search_timing_source=search_timing_source,
|
|
|
)
|
|
|
return {
|
|
|
"discovered_content_items": self.discovered_content_items[start_discovered:],
|
|
|
@@ -190,9 +223,10 @@ class _ProgressiveContext:
|
|
|
}
|
|
|
|
|
|
def _process_query_pages(self, query: dict[str, Any]) -> None:
|
|
|
- first_page = self._fetch_page(query, page_number=1, cursor=query.get("page_cursor"))
|
|
|
- if first_page is None:
|
|
|
+ first_fetch = self._fetch_page(query, page_number=1, cursor=query.get("page_cursor"))
|
|
|
+ if first_fetch is None:
|
|
|
return
|
|
|
+ first_page = first_fetch.results
|
|
|
page_count = len(first_page)
|
|
|
if page_count == 0:
|
|
|
return
|
|
|
@@ -206,6 +240,10 @@ class _ProgressiveContext:
|
|
|
page_item_count=page_count,
|
|
|
batch_kind="initial_top3",
|
|
|
rank_offset=0,
|
|
|
+ search_duration_ms=first_fetch.search_duration_ms,
|
|
|
+ search_wait_ms=first_fetch.search_wait_ms,
|
|
|
+ search_request_duration_ms=first_fetch.search_request_duration_ms,
|
|
|
+ search_timing_source="page_fetch",
|
|
|
)
|
|
|
if not passed_first or not remainder_batch:
|
|
|
return
|
|
|
@@ -217,6 +255,10 @@ class _ProgressiveContext:
|
|
|
page_item_count=page_count,
|
|
|
batch_kind="page_remainder",
|
|
|
rank_offset=INITIAL_BATCH_SIZE,
|
|
|
+ search_duration_ms=0,
|
|
|
+ search_wait_ms=0,
|
|
|
+ search_request_duration_ms=0,
|
|
|
+ search_timing_source="same_page_remainder",
|
|
|
)
|
|
|
if not passed_remainder:
|
|
|
return
|
|
|
@@ -225,9 +267,10 @@ class _ProgressiveContext:
|
|
|
has_more = _page_has_more(first_page)
|
|
|
page_number = 2
|
|
|
while page_number <= MAX_PAGES and has_more and cursor:
|
|
|
- page = self._fetch_page(query, page_number=page_number, cursor=cursor)
|
|
|
- if page is None or not page:
|
|
|
+ fetched_page = self._fetch_page(query, page_number=page_number, cursor=cursor)
|
|
|
+ if fetched_page is None or not fetched_page.results:
|
|
|
return
|
|
|
+ page = fetched_page.results
|
|
|
self._queries_with_consumed_next_pages.add(str(query["search_query_id"]))
|
|
|
page_count = len(page)
|
|
|
passed_page = self._process_batch(
|
|
|
@@ -237,6 +280,10 @@ class _ProgressiveContext:
|
|
|
page_item_count=page_count,
|
|
|
batch_kind=f"page_{page_number:02d}",
|
|
|
rank_offset=0,
|
|
|
+ search_duration_ms=fetched_page.search_duration_ms,
|
|
|
+ search_wait_ms=fetched_page.search_wait_ms,
|
|
|
+ search_request_duration_ms=fetched_page.search_request_duration_ms,
|
|
|
+ search_timing_source="page_fetch",
|
|
|
)
|
|
|
if page_number >= MAX_PAGES or not passed_page:
|
|
|
return
|
|
|
@@ -250,19 +297,27 @@ class _ProgressiveContext:
|
|
|
*,
|
|
|
page_number: int,
|
|
|
cursor: Any,
|
|
|
- ) -> list[dict[str, Any]] | None:
|
|
|
+ ) -> _FetchedPage | None:
|
|
|
page_query = dict(query)
|
|
|
if page_number > 1:
|
|
|
page_query["page_cursor"] = str(cursor or "")
|
|
|
try:
|
|
|
- if self.limiter is not None:
|
|
|
- self.limiter.wait()
|
|
|
+ search_started_at = time.monotonic()
|
|
|
+ waited_seconds = self.limiter.wait() if self.limiter is not None else 0.0
|
|
|
+ request_started_at = time.monotonic()
|
|
|
search = getattr(self.platform_client, "search_full_page_metadata", None)
|
|
|
if not callable(search):
|
|
|
search = getattr(self.platform_client, "search_full_page", None)
|
|
|
if not callable(search):
|
|
|
search = self.platform_client.search
|
|
|
- return list(search(page_query))
|
|
|
+ results = list(search(page_query))
|
|
|
+ request_ended_at = time.monotonic()
|
|
|
+ return _FetchedPage(
|
|
|
+ results=results,
|
|
|
+ search_duration_ms=_elapsed_ms(search_started_at, request_ended_at),
|
|
|
+ search_wait_ms=int(waited_seconds * 1000),
|
|
|
+ search_request_duration_ms=_elapsed_ms(request_started_at, request_ended_at),
|
|
|
+ )
|
|
|
except Exception as exc:
|
|
|
failure_query = page_query if page_number == 1 else {
|
|
|
**page_query,
|
|
|
@@ -282,7 +337,12 @@ class _ProgressiveContext:
|
|
|
page_item_count: int,
|
|
|
batch_kind: str,
|
|
|
rank_offset: int,
|
|
|
+ search_duration_ms: int = 0,
|
|
|
+ search_wait_ms: int = 0,
|
|
|
+ search_request_duration_ms: int = 0,
|
|
|
+ search_timing_source: str = "unknown",
|
|
|
) -> bool:
|
|
|
+ batch_started_at = time.monotonic()
|
|
|
batch = []
|
|
|
for rank, result in enumerate(page_results, start=1):
|
|
|
key = _content_key(result)
|
|
|
@@ -318,6 +378,7 @@ class _ProgressiveContext:
|
|
|
if not batch:
|
|
|
return False
|
|
|
|
|
|
+ discovery_started_at = time.monotonic()
|
|
|
discovered = content_discovery.run(
|
|
|
self.run_id,
|
|
|
self.policy_run_id,
|
|
|
@@ -326,6 +387,7 @@ class _ProgressiveContext:
|
|
|
self.runtime,
|
|
|
write_runtime=False,
|
|
|
)
|
|
|
+ discovery_duration_ms = _elapsed_ms(discovery_started_at, time.monotonic())
|
|
|
recalled = pattern_recall.run(
|
|
|
self.run_id,
|
|
|
self.policy_run_id,
|
|
|
@@ -339,7 +401,11 @@ class _ProgressiveContext:
|
|
|
write_runtime=False,
|
|
|
archive_dispatcher=self.archive_dispatcher,
|
|
|
)
|
|
|
+ recall_timing = recalled.get("batch_timing") or {}
|
|
|
+ portrait_started_at = time.monotonic()
|
|
|
self._inject_fifty_plus(recalled["evidence_bundles"])
|
|
|
+ portrait_duration_ms = _elapsed_ms(portrait_started_at, time.monotonic())
|
|
|
+ rule_started_at = time.monotonic()
|
|
|
decisions = rule_judgment.run(
|
|
|
self.run_id,
|
|
|
self.policy_run_id,
|
|
|
@@ -349,8 +415,29 @@ class _ProgressiveContext:
|
|
|
start_index=self._decision_index_base + len(self.rule_decisions) + 1,
|
|
|
write_runtime=False,
|
|
|
)
|
|
|
+ rule_duration_ms = _elapsed_ms(rule_started_at, time.monotonic())
|
|
|
self._accumulate(batch, recalled, decisions)
|
|
|
- return any(decision.get("decision_action") == PASS_ACTION for decision in decisions)
|
|
|
+ has_pass = any(decision.get("decision_action") == PASS_ACTION for decision in decisions)
|
|
|
+ self._record_batch_timing_event(
|
|
|
+ query,
|
|
|
+ batch,
|
|
|
+ decisions,
|
|
|
+ page_number=page_number,
|
|
|
+ page_item_count=page_item_count,
|
|
|
+ batch_kind=batch_kind,
|
|
|
+ search_duration_ms=search_duration_ms,
|
|
|
+ search_wait_ms=search_wait_ms,
|
|
|
+ search_request_duration_ms=search_request_duration_ms,
|
|
|
+ search_timing_source=search_timing_source,
|
|
|
+ oss_batch_duration_ms=int(recall_timing.get("oss_batch_duration_ms") or 0),
|
|
|
+ qwen_batch_duration_ms=int(recall_timing.get("qwen_batch_duration_ms") or 0),
|
|
|
+ portrait_duration_ms=portrait_duration_ms,
|
|
|
+ rule_duration_ms=rule_duration_ms,
|
|
|
+ discovery_duration_ms=discovery_duration_ms,
|
|
|
+ batch_duration_ms=_elapsed_ms(batch_started_at, time.monotonic()),
|
|
|
+ has_pass=has_pass,
|
|
|
+ )
|
|
|
+ return has_pass
|
|
|
|
|
|
def _inject_fifty_plus(self, bundles: list[dict[str, Any]]) -> None:
|
|
|
"""M9A:仅抖音,对相关性达标(query≥PORTRAIT_QUERY_GATE)的视频拉作者画像补 50+。
|
|
|
@@ -385,8 +472,8 @@ class _ProgressiveContext:
|
|
|
portrait, fetch_error = None, None
|
|
|
for attempt in range(PORTRAIT_FETCH_ATTEMPTS): # 重试,救瞬时失败/限速
|
|
|
try:
|
|
|
- if self.limiter is not None:
|
|
|
- self.limiter.wait()
|
|
|
+ if self.portrait_limiter is not None:
|
|
|
+ self.portrait_limiter.wait()
|
|
|
portrait = fetch_portrait(author_id)
|
|
|
fetch_error = None
|
|
|
break
|
|
|
@@ -412,6 +499,81 @@ class _ProgressiveContext:
|
|
|
}
|
|
|
bundle["content_audience_50plus"] = block
|
|
|
|
|
|
+ def _record_batch_timing_event(
|
|
|
+ self,
|
|
|
+ query: dict[str, Any],
|
|
|
+ batch: list[dict[str, Any]],
|
|
|
+ decisions: list[dict[str, Any]],
|
|
|
+ *,
|
|
|
+ page_number: int,
|
|
|
+ page_item_count: int,
|
|
|
+ batch_kind: str,
|
|
|
+ search_duration_ms: int,
|
|
|
+ search_wait_ms: int,
|
|
|
+ search_request_duration_ms: int,
|
|
|
+ search_timing_source: str,
|
|
|
+ oss_batch_duration_ms: int,
|
|
|
+ qwen_batch_duration_ms: int,
|
|
|
+ portrait_duration_ms: int,
|
|
|
+ rule_duration_ms: int,
|
|
|
+ discovery_duration_ms: int,
|
|
|
+ batch_duration_ms: int,
|
|
|
+ has_pass: bool,
|
|
|
+ ) -> None:
|
|
|
+ self._batch_event_index += 1
|
|
|
+ action_counts: dict[str, int] = {}
|
|
|
+ for decision in decisions:
|
|
|
+ action = str(decision.get("decision_action") or "unknown")
|
|
|
+ action_counts[action] = action_counts.get(action, 0) + 1
|
|
|
+ raw_payload = {
|
|
|
+ "batch_timing_schema_version": "progressive_batch_timing.v1",
|
|
|
+ "progressive_batch_id": (
|
|
|
+ f"{query['search_query_id']}_p{page_number:02d}_{batch_kind}"
|
|
|
+ ),
|
|
|
+ "search_query_id": query.get("search_query_id"),
|
|
|
+ "search_query": query.get("search_query"),
|
|
|
+ "search_query_generation_method": query.get("search_query_generation_method"),
|
|
|
+ "page_number": page_number,
|
|
|
+ "page_item_count": page_item_count,
|
|
|
+ "batch_kind": batch_kind,
|
|
|
+ "batch_video_count": len(batch),
|
|
|
+ "decision_action_counts": action_counts,
|
|
|
+ "has_add_to_content_pool": has_pass,
|
|
|
+ "search_duration_ms": search_duration_ms,
|
|
|
+ "search_wait_ms": search_wait_ms,
|
|
|
+ "search_request_duration_ms": search_request_duration_ms,
|
|
|
+ "search_timing_source": search_timing_source,
|
|
|
+ "oss_batch_duration_ms": oss_batch_duration_ms,
|
|
|
+ "qwen_batch_duration_ms": qwen_batch_duration_ms,
|
|
|
+ "portrait_duration_ms": portrait_duration_ms,
|
|
|
+ "rule_duration_ms": rule_duration_ms,
|
|
|
+ "discovery_duration_ms": discovery_duration_ms,
|
|
|
+ "batch_duration_ms": batch_duration_ms,
|
|
|
+ "content_ids": [str(item.get("platform_content_id") or "") for item in batch],
|
|
|
+ }
|
|
|
+ row = with_raw_payload(
|
|
|
+ {
|
|
|
+ "record_schema_version": RUNTIME_RECORD_SCHEMA_VERSION,
|
|
|
+ "run_id": self.run_id,
|
|
|
+ "policy_run_id": self.policy_run_id,
|
|
|
+ "event_id": (
|
|
|
+ f"evt_progressive_batch_{self._batch_event_index:04d}_"
|
|
|
+ f"{_safe_token(query.get('search_query_id'))}_p{page_number:02d}_{batch_kind}"
|
|
|
+ ),
|
|
|
+ "event_type": "progressive_batch_timing",
|
|
|
+ "status": "success",
|
|
|
+ "input_ref": "platform_results",
|
|
|
+ "output_ref": "rule_decisions",
|
|
|
+ "error_code": None,
|
|
|
+ "message": (
|
|
|
+ f"{batch_kind} videos={len(batch)} add={action_counts.get(PASS_ACTION, 0)}"
|
|
|
+ ),
|
|
|
+ "created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ row["raw_payload"].update(raw_payload)
|
|
|
+ self.runtime.append_jsonl(self.run_id, "run_events.jsonl", [row])
|
|
|
+
|
|
|
def _prepare_result(
|
|
|
self,
|
|
|
query: dict[str, Any],
|
|
|
@@ -571,6 +733,15 @@ def _get_path(data: Any, path: str) -> Any:
|
|
|
return cur
|
|
|
|
|
|
|
|
|
+def _elapsed_ms(start: float, end: float) -> int:
|
|
|
+ return max(0, int((end - start) * 1000))
|
|
|
+
|
|
|
+
|
|
|
+def _safe_token(value: Any) -> str:
|
|
|
+ token = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in str(value or "query"))
|
|
|
+ return token[:80] or "query"
|
|
|
+
|
|
|
+
|
|
|
def _is_number(value: Any) -> bool:
|
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
|
|